Skip to content
Open
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
24 changes: 24 additions & 0 deletions .changeset/add-enabled-prop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
"agents": minor
---

feat: add `enabled` prop to `useAgent` hook for conditional connections

This adds an `enabled` optional prop to `useAgent` that allows conditionally connecting to an Agent. When `enabled` is `false`, the connection will not be established. When it transitions from `false` to `true`, the connection is established. When it transitions from `true` to `false`, the connection is closed.

This is useful for:

- Auth-based conditional connections (only connect when authenticated)
- Feature flag based connections
- Lazy loading patterns

Usage:

```tsx
const agent = useAgent({
agent: "my-agent",
enabled: isAuthenticated // only connect when authenticated
});
```

Closes #533
210 changes: 210 additions & 0 deletions packages/agents/src/react-tests/enabled-prop.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
import { describe, expect, it, beforeEach } from "vitest";

/**
* Tests for the `enabled` prop in useAgent hook.
*
* The `enabled` prop follows the React Query pattern for conditional connections:
* - When `enabled` is `false`, the WebSocket connection is not established
* - When `enabled` is `true` (default), the connection is established normally
* - When `enabled` transitions from `false` to `true`, the connection is opened
* - When `enabled` transitions from `true` to `false`, the connection is closed
*
* @see https://github.com/cloudflare/agents/issues/533
*/

describe("useAgent enabled prop (issue #533)", () => {
describe("Type definitions", () => {
it("should accept enabled as an optional boolean prop", () => {
// This is a compile-time check - if UseAgentOptions doesn't include enabled,
// TypeScript would fail. We're just documenting the expected type here.
type ExpectedOptions = {
agent: string;
name?: string;
enabled?: boolean;
};

const options: ExpectedOptions = {
agent: "test-agent",
enabled: false
};

expect(options.enabled).toBe(false);
});

it("should default enabled to true when not specified", () => {
const optionsWithEnabled = { agent: "test", enabled: true };
const optionsWithoutEnabled: { agent: string; enabled?: boolean } = {
agent: "test"
};

// Default behavior: enabled should be true when undefined
const defaultEnabled = optionsWithoutEnabled.enabled ?? true;
expect(defaultEnabled).toBe(true);
expect(optionsWithEnabled.enabled).toBe(true);
});
});

describe("Connection lifecycle", () => {
it("should start closed when enabled is false", () => {
// When enabled=false, startClosed should be passed as true to usePartySocket
const enabled = false;
const startClosed = !enabled;

expect(startClosed).toBe(true);
});

it("should start open when enabled is true", () => {
// When enabled=true (default), startClosed should be false
const enabled = true;
const startClosed = !enabled;

expect(startClosed).toBe(false);
});

it("should start open when enabled is undefined (default)", () => {
// Simulate the default behavior when enabled is not provided
const enabledFromOptions: boolean | undefined = undefined;
const enabled = enabledFromOptions ?? true;
const startClosed = !enabled;

expect(startClosed).toBe(false);
});
});

describe("State transition logic", () => {
let wasEnabled: boolean;
let currentEnabled: boolean;
let reconnectCalled: boolean;
let closeCalled: boolean;

beforeEach(() => {
reconnectCalled = false;
closeCalled = false;
});

function simulateTransition(prev: boolean, current: boolean) {
wasEnabled = prev;
currentEnabled = current;

// Simulate the useEffect logic
if (!wasEnabled && currentEnabled) {
reconnectCalled = true;
} else if (wasEnabled && !currentEnabled) {
closeCalled = true;
}
}

it("should call reconnect when transitioning from disabled to enabled", () => {
simulateTransition(false, true);

expect(reconnectCalled).toBe(true);
expect(closeCalled).toBe(false);
});

it("should call close when transitioning from enabled to disabled", () => {
simulateTransition(true, false);

expect(reconnectCalled).toBe(false);
expect(closeCalled).toBe(true);
});

it("should not call either when staying enabled", () => {
simulateTransition(true, true);

expect(reconnectCalled).toBe(false);
expect(closeCalled).toBe(false);
});

it("should not call either when staying disabled", () => {
simulateTransition(false, false);

expect(reconnectCalled).toBe(false);
expect(closeCalled).toBe(false);
});
});

describe("Integration with other options", () => {
it("should work alongside startClosed option (enabled takes precedence)", () => {
// If user passes both startClosed and enabled, enabled should win
// because it's destructured before restOptions spread
const options = {
agent: "test",
enabled: false,
startClosed: false // This should be overridden
};

const { enabled, startClosed: _userStartClosed } = options;
const effectiveStartClosed = !enabled; // enabled takes precedence

expect(effectiveStartClosed).toBe(true);
});

it("should preserve other options when enabled is specified", () => {
const options: {
agent: string;
name: string;
enabled: boolean;
cacheTtl: number;
queryDeps: string[];
} = {
agent: "test-agent",
name: "instance-1",
enabled: false,
cacheTtl: 60000,
queryDeps: ["dep1"]
};

const { queryDeps, cacheTtl, enabled, ...restOptions } = options;

expect(restOptions.agent).toBe("test-agent");
expect(restOptions.name).toBe("instance-1");
expect(enabled).toBe(false);
expect(cacheTtl).toBe(60000);
expect(queryDeps).toEqual(["dep1"]);
});
});

describe("Common use cases", () => {
it("should support authentication-based conditional connection", () => {
// Simulate: only connect when user is authenticated
const isAuthenticated = false;

const options = {
agent: "chat-agent",
enabled: isAuthenticated
};

expect(options.enabled).toBe(false);
expect(!options.enabled).toBe(true); // startClosed would be true
});

it("should support feature flag based conditional connection", () => {
// Simulate: only connect when feature is enabled
const featureEnabled = true;

const options = {
agent: "experimental-agent",
enabled: featureEnabled
};

expect(options.enabled).toBe(true);
expect(!options.enabled).toBe(false); // startClosed would be false
});

it("should support lazy loading pattern", () => {
// Simulate: connect only when user navigates to a specific section
let userOnAgentPage = false;

const getOptions = () => ({
agent: "page-agent",
enabled: userOnAgentPage
});

expect(getOptions().enabled).toBe(false);

// User navigates to the page
userOnAgentPage = true;
expect(getOptions().enabled).toBe(true);
});
});
});
38 changes: 37 additions & 1 deletion packages/agents/src/react.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,15 @@ export type UseAgentOptions<State = unknown> = Omit<
onStateUpdate?: (state: State, source: "server" | "client") => void;
/** Called when MCP server state is updated */
onMcpUpdate?: (mcpServers: MCPServersState) => void;
/**
* Whether the WebSocket connection should be enabled.
* When `false`, the connection will not be established.
* When transitioning from `false` to `true`, the connection will be opened.
* When transitioning from `true` to `false`, the connection will be closed.
* Follows the React Query `enabled` pattern for conditional data fetching.
* @default true
*/
enabled?: boolean;
};

type AllOptional<T> = T extends [infer A, ...infer R]
Expand Down Expand Up @@ -252,7 +261,16 @@ export function useAgent<State>(
stub: UntypedAgentStub;
} {
const agentNamespace = camelCaseToKebabCase(options.agent);
const { query, queryDeps, cacheTtl, ...restOptions } = options;
const {
query,
queryDeps,
cacheTtl,
enabled = true,
...restOptions
} = options;

// Track the previous enabled state for connection lifecycle management
const prevEnabledRef = useRef(enabled);

// Keep track of pending RPC calls
const pendingCallsRef = useRef(
Expand Down Expand Up @@ -362,6 +380,7 @@ export function useAgent<State>(
prefix: "agents",
room: options.name || "default",
query: resolvedQuery,
startClosed: !enabled,
...restOptions,
onMessage: (message) => {
if (typeof message.data === "string") {
Expand Down Expand Up @@ -419,6 +438,23 @@ export function useAgent<State>(
call: UntypedAgentMethodCall;
stub: UntypedAgentStub;
};

// Handle enabled state transitions
// Reconnect when enabled changes from false to true
// Close connection when enabled changes from true to false
useEffect(() => {
const wasEnabled = prevEnabledRef.current;
prevEnabledRef.current = enabled;

if (!wasEnabled && enabled) {
// Transition: disabled -> enabled, open the connection
agent.reconnect();
} else if (wasEnabled && !enabled) {
// Transition: enabled -> disabled, close the connection
agent.close();
}
}, [enabled, agent]);

// Create the call method
const call = useCallback(
<T = unknown,>(
Expand Down
Loading