Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/fix-basepath-reconnect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agents": patch
---

Fix `useAgent` and `AgentClient` crashing when using `basePath` routing. `PartySocket.reconnect()` requires `room` to be set, but `basePath` mode bypasses room-based URL construction. The fix provides `room` and `party` in socket options even when `basePath` is used, as a workaround pending a fix in partysocket.
47 changes: 42 additions & 5 deletions examples/playground/src/demos/core/RoutingDemo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { LogPanel, ConnectionStatus } from "../../components";
import { useLogs } from "../../hooks";
import type { RoutingAgent, RoutingAgentState } from "./routing-agent";

type RoutingStrategy = "per-user" | "shared" | "per-session";
type RoutingStrategy = "per-user" | "shared" | "per-session" | "custom-path";

function getStoredUserId(): string {
if (typeof window === "undefined") return "user-1";
Expand Down Expand Up @@ -43,19 +43,40 @@ export function RoutingDemo() {
return "routing-shared";
case "per-session":
return `routing-${getSessionId()}`;
case "custom-path":
return `routing-${userId}`;
default:
return "routing-demo";
}
};

const currentAgentName = getAgentName();
const isCustomPath = strategy === "custom-path";

const agent = useAgent<RoutingAgent, RoutingAgentState>({
agent: "routing-agent",
name: currentAgentName,
// When using basePath, the server handles routing — name is ignored
name: isCustomPath ? undefined : currentAgentName,
// basePath bypasses the default /agents/{agent}/{name} URL construction
// and connects directly to this path, where the server routes to the agent.
// Note: basePath should NOT start with a slash (the URL already includes one).
basePath: isCustomPath ? `custom-routing/${currentAgentName}` : undefined,
onOpen: () => {
addLog("info", "connected", `Agent: ${currentAgentName}`);
setAgentInstanceName(currentAgentName);
if (!isCustomPath) {
addLog("info", "connected", `Agent: ${currentAgentName}`);
setAgentInstanceName(currentAgentName);
} else {
addLog(
"info",
"connected",
`Custom path: /custom-routing/${currentAgentName}`
);
}
},
onIdentity: (name, agentType) => {
// When using basePath, the server sends the identity after connection
addLog("info", "identity", `Server resolved: ${agentType}/${name}`);
setAgentInstanceName(name);
},
onClose: () => addLog("info", "disconnected"),
onError: () => addLog("error", "error", "Connection error"),
Expand Down Expand Up @@ -93,13 +114,19 @@ export function RoutingDemo() {
id: "per-session",
label: "Per-Session",
description: "Each browser session gets its own agent"
},
{
id: "custom-path",
label: "Custom Path (basePath)",
description:
"Server-side routing via a custom URL path using getAgentByName"
}
];

return (
<DemoWrapper
title="Routing Strategies"
description="Different agent naming patterns for different use cases. The 'name' parameter determines which agent instance you connect to."
description="Different agent routing patterns for different use cases. Use 'name' to select an agent instance, or 'basePath' to route via a custom server-side path."
>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Controls */}
Expand Down Expand Up @@ -252,6 +279,16 @@ export function RoutingDemo() {
Each browser tab gets its own agent
</span>
</p>
<p>
<strong>Custom Path:</strong> basePath ={" "}
<code>/custom-routing/routing-{userId}</code>
<br />
<span className="text-xs">
Server handles routing via <code>getAgentByName</code> —
client uses <code>basePath</code> instead of{" "}
<code>agent</code>/<code>name</code>
</span>
</p>
</div>
</div>
</div>
Expand Down
16 changes: 15 additions & 1 deletion examples/playground/src/server.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { routeAgentRequest, routeAgentEmail } from "agents";
import { routeAgentRequest, routeAgentEmail, getAgentByName } from "agents";
import {
createAddressBasedEmailResolver,
createSecureReplyEmailResolver
Expand Down Expand Up @@ -91,6 +91,20 @@ export default {
},

async fetch(request: Request, env: Env, _ctx: ExecutionContext) {
const url = new URL(request.url);

// Custom basePath routing example:
// Routes /custom-routing/{instanceName} to a RoutingAgent instance.
// The server controls which agent instance handles the request,
// and the client connects using `basePath` instead of `agent` + `name`.
if (url.pathname.startsWith("/custom-routing/")) {
const instanceName = url.pathname.replace("/custom-routing/", "");
if (instanceName) {
const agent = await getAgentByName(env.RoutingAgent, instanceName);
return agent.fetch(request);
}
}

return (
(await routeAgentRequest(request, env)) ||
new Response("Not found", { status: 404 })
Expand Down
14 changes: 12 additions & 2 deletions packages/agents/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,9 +162,19 @@ export class AgentClient<State = unknown> extends PartySocket {
constructor(options: AgentClientOptions<State>) {
const agentNamespace = camelCaseToKebabCase(options.agent);

// If basePath is provided, use it directly; otherwise construct from agent/name
// If basePath is provided, use it directly; otherwise construct from agent/name.
// WORKAROUND: When using basePath, we still set `room` and `party` because
// PartySocket.reconnect() requires `room` to be set, even though basePath bypasses
// the room-based URL construction. This should be removed once partysocket is fixed
// to skip the `room` check when `basePath` is provided.
const socketOptions = options.basePath
? { basePath: options.basePath, path: options.path, ...options }
? {
basePath: options.basePath,
party: agentNamespace,
room: options.name || "default",
path: options.path,
...options
}
: {
party: agentNamespace,
prefix: "agents",
Expand Down
2 changes: 1 addition & 1 deletion packages/agents/src/mcp/handler.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import type { Server } from "@modelcontextprotocol/sdk/server/index.js";
import {
WorkerTransport,
type WorkerTransportOptions
Expand Down
4 changes: 2 additions & 2 deletions packages/agents/src/react-tests/cache-invalidation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -395,7 +395,7 @@ describe("Cache invalidation on disconnect", () => {
it("should invalidate correct cache entry when name changes before disconnect", async () => {
const { host, protocol } = getTestWorkerHost();
let capturedAgent: TestAgent | null = null;
let setNameFn: ((name: string) => void) | null = null;
let _setNameFn: ((name: string) => void) | null = null;

// Pre-populate cache entries for both names
const cacheKeyName1 = createCacheKey(
Expand Down Expand Up @@ -433,7 +433,7 @@ describe("Cache invalidation on disconnect", () => {
capturedAgent = agent;
}}
onNameChange={(setName) => {
setNameFn = setName;
_setNameFn = setName;
}}
/>
</SuspenseWrapper>
Expand Down
81 changes: 81 additions & 0 deletions packages/agents/src/react-tests/useAgent.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,87 @@ describe("useAgent hook", () => {
});
});

describe("basePath routing", () => {
it("should connect and receive identity via basePath", async () => {
const { host, protocol } = getTestWorkerHost();
const onIdentity = vi.fn();
let capturedAgent: TestAgent | null = null;

const instanceName = `basepath-hook-${Date.now()}`;

const { container } = render(
<SuspenseWrapper>
<TestAgentComponent
options={{
agent: "TestStateAgent",
name: instanceName,
host,
protocol,
basePath: `custom-state/${instanceName}`,
onIdentity
}}
onAgent={(agent) => {
capturedAgent = agent;
}}
/>
</SuspenseWrapper>
);

await vi.waitFor(
() => {
const status = container.querySelector(
'[data-testid="agent-status"]'
);
expect(status?.textContent).toBe("connected");
},
{ timeout: 10000 }
);

expect(capturedAgent).not.toBeNull();
expect(capturedAgent!.identified).toBe(true);
// Server should send back the correct identity
expect(onIdentity).toHaveBeenCalledWith(instanceName, "test-state-agent");
});

it("should connect via server-determined basePath routing", async () => {
const { host, protocol } = getTestWorkerHost();
const onIdentity = vi.fn();
let capturedAgent: TestAgent | null = null;

const { container } = render(
<SuspenseWrapper>
<TestAgentComponent
options={{
agent: "TestStateAgent",
host,
protocol,
basePath: "user",
onIdentity
}}
onAgent={(agent) => {
capturedAgent = agent;
}}
/>
</SuspenseWrapper>
);

await vi.waitFor(
() => {
const status = container.querySelector(
'[data-testid="agent-status"]'
);
expect(status?.textContent).toBe("connected");
},
{ timeout: 10000 }
);

expect(capturedAgent).not.toBeNull();
expect(capturedAgent!.identified).toBe(true);
// Server routes /user to "auth-user" instance
expect(onIdentity).toHaveBeenCalledWith("auth-user", "test-state-agent");
});
});

describe("stub proxy behavior", () => {
it("should not trigger RPC for internal methods like toJSON", async () => {
const { host, protocol } = getTestWorkerHost();
Expand Down
8 changes: 7 additions & 1 deletion packages/agents/src/react.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -433,10 +433,16 @@ export function useAgent<State>(
resetReady();
}

// If basePath is provided, use it directly; otherwise construct from agent/name
// If basePath is provided, use it directly; otherwise construct from agent/name.
// WORKAROUND: When using basePath, we still set `room` and `party` because
// PartySocket.reconnect() requires `room` to be set, even though basePath bypasses
// the room-based URL construction. This should be removed once partysocket is fixed
// to skip the `room` check when `basePath` is provided.
const socketOptions = options.basePath
? {
basePath: options.basePath,
party: agentNamespace,
room: options.name || "default",
path: options.path,
query: resolvedQuery,
...restOptions
Expand Down
4 changes: 2 additions & 2 deletions site/ai-playground/src/components/McpServers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export function McpServers({ agent, mcpState, mcpLogs }: McpServersProps) {
);
const [showSettings, setShowSettings] = useState(false);
const [showLocalhostWarning, setShowLocalhostWarning] = useState(false);
const [error, setError] = useState<string>("");
const [_error, setError] = useState<string>("");
const [isConnecting, setIsConnecting] = useState(false);
const [disconnectingServerId, setDisconnectingServerId] = useState<
string | null
Expand Down Expand Up @@ -529,7 +529,7 @@ export function McpServers({ agent, mcpState, mcpLogs }: McpServersProps) {
</button>
</div>
{server.state === "failed" && server.error && (
<div className="mt-2 text-xs text-red-600 break-words">
<div className="mt-2 text-xs text-red-600 wrap-break-word">
{server.error}
</div>
)}
Expand Down
Loading