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/mean-buttons-sit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agents": patch
---

Implement createStubProxy function to fix RPC method call handling
65 changes: 65 additions & 0 deletions packages/agents/src/react-tests/stub-tojson.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { describe, expect, it, vi } from "vitest";
import { _testUtils } from "../react";

/**
* The stub proxy should not make RPC calls for internal JS methods like toJSON,
* which are accessed by console.log and JSON.stringify.
*/

const { createStubProxy } = _testUtils;

describe("Stub proxy toJSON handling (issue #753)", () => {
it("does not trigger RPC for toJSON", () => {
const mockCall = vi.fn();
const stub = createStubProxy(mockCall);

expect(stub.toJSON).toBeUndefined();
expect(mockCall).not.toHaveBeenCalled();
});

it("does not trigger RPC for other internal methods", () => {
const mockCall = vi.fn();
const stub = createStubProxy(mockCall);

expect(stub.then).toBeUndefined();
expect(stub.valueOf).toBeUndefined();
expect(stub.toString).toBeUndefined();
expect(mockCall).not.toHaveBeenCalled();
});

it("still allows regular RPC method calls", () => {
const mockCall = vi.fn();
const stub = createStubProxy(mockCall);

(stub.myMethod as Function)("arg1", 123);

expect(mockCall).toHaveBeenCalledWith("myMethod", ["arg1", 123]);
});

it("allows JSON.stringify without RPC calls", () => {
const mockCall = vi.fn();
const stub = createStubProxy(mockCall);

const result = JSON.stringify({ data: "test", stub });

expect(result).toBe('{"data":"test","stub":{}}');
expect(mockCall).not.toHaveBeenCalled();
});

it("handles MessageEvent.target.stub serialization (issue scenario)", () => {
const mockCall = vi.fn();
const stub = createStubProxy(mockCall);

// Simulate the exact scenario: MessageEvent with target containing stub
const messageEvent = {
data: '{"type":"message"}',
target: { stub }
};

// Simulate console.log traversing and checking toJSON
expect(messageEvent.target.stub.toJSON).toBeUndefined();
JSON.stringify(messageEvent);

expect(mockCall).not.toHaveBeenCalled();
});
});
54 changes: 42 additions & 12 deletions packages/agents/src/react.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,13 +73,53 @@ function deleteCacheEntry(key: string): void {
queryCache.delete(key);
}

/**
* Creates a proxy that wraps RPC method calls.
* Internal JS methods (toJSON, then, etc.) return undefined to avoid
* triggering RPC calls during serialization (e.g., console.log)
*/
function createStubProxy<T = Record<string, Method>>(
call: (method: string, args: unknown[]) => unknown
): T {
// biome-ignore lint/suspicious/noExplicitAny: proxy needs any for dynamic method access
return new Proxy<any>(
{},
{
get: (_target, method) => {
// Skip internal JavaScript methods that shouldn't trigger RPC calls.
// These are commonly accessed by console.log, JSON.stringify, and other
// serialization utilities.
if (
typeof method === "symbol" ||
method === "toJSON" ||
method === "then" ||
method === "catch" ||
method === "finally" ||
method === "valueOf" ||
method === "toString" ||
method === "constructor" ||
method === "prototype" ||
method === "$$typeof" ||
method === "@@toStringTag" ||
method === "asymmetricMatch" ||
method === "nodeType"
) {
return undefined;
}
return (...args: unknown[]) => call(method as string, args);
}
}
);
}

// Export for testing purposes
export const _testUtils = {
queryCache,
setCacheEntry,
getCacheEntry,
deleteCacheEntry,
clearCache: () => queryCache.clear()
clearCache: () => queryCache.clear(),
createStubProxy
};

/**
Expand Down Expand Up @@ -415,17 +455,7 @@ export function useAgent<State>(
agent.call = call;
agent.agent = agentNamespace;
agent.name = options.name || "default";
// biome-ignore lint: suppressions/parse
agent.stub = new Proxy<any>(
{},
{
get: (_target, method) => {
return (...args: unknown[]) => {
return call(method as string, args);
};
}
}
);
agent.stub = createStubProxy(call);

// warn if agent isn't in lowercase
if (agent.agent !== agent.agent.toLowerCase()) {
Expand Down
Loading