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
65 changes: 48 additions & 17 deletions integrations/langgraph/typescript/src/__tests__/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,32 @@ describe("header forwarding via onRequest hook", () => {
});
});

describe("clone run-local state isolation", () => {
it("creates fresh run-local state on the cloned agent", () => {
const agent = new LangGraphAgent({
graphId: "test-graph",
deploymentUrl: "http://localhost:8000",
});

(agent as any).emittedToolCallStartIds.add("tool-call-1");
(agent as any).eventsStreamActive = true;

const cloned = agent.clone() as LangGraphAgent;

expect(cloned).toBeInstanceOf(LangGraphAgent);
expect((cloned as any).emittedToolCallStartIds).toBeInstanceOf(Set);
expect((cloned as any).emittedToolCallStartIds).not.toBe(
(agent as any).emittedToolCallStartIds,
);
expect([...(cloned as any).emittedToolCallStartIds]).toEqual([]);
expect((cloned as any).eventsStreamActive).toBe(false);
expect((agent as any).emittedToolCallStartIds.has("tool-call-1")).toBe(
true,
);
expect((agent as any).eventsStreamActive).toBe(true);
});
});

// ─── Part C: forwarded-headers payload injection ─────────────────────────────
//
// CopilotKit Runtime writes per-request x-* headers (correlation IDs, x-aimock-context,
Expand Down Expand Up @@ -595,12 +621,19 @@ describe("langGraphDefaultMergeState forwards props into ag-ui state", () => {
context: [],
forwardedProps,
} as any;
return (agent as any).langGraphDefaultMergeState({ messages: [] }, [], input);
return (agent as any).langGraphDefaultMergeState(
{ messages: [] },
[],
input,
);
}

it("surfaces each configured forwarded prop under its ag-ui state key", () => {
const forwarded = Object.fromEntries(
Object.entries(FORWARDED_PROPS_TO_AGUI).map(([fp, [, sample]]) => [fp, sample]),
Object.entries(FORWARDED_PROPS_TO_AGUI).map(([fp, [, sample]]) => [
fp,
sample,
]),
);
const result = mergeWith(forwarded);
for (const [aguiKey, sample] of Object.values(FORWARDED_PROPS_TO_AGUI)) {
Expand Down Expand Up @@ -642,9 +675,7 @@ describe("dispatchInterruptFinish produces correct AG-UI protocol events", () =>
lgInterrupts: [{ value: { reason: "confirm" }, id: "int-1" }],
});

const finished = events.find(
(e: any) => e.type === "RUN_FINISHED",
);
const finished = events.find((e: any) => e.type === "RUN_FINISHED");
expect(finished).toBeDefined();
expect(finished.outcome.type).toBe("interrupt");
expect(finished.outcome.interrupts).toHaveLength(1);
Expand Down Expand Up @@ -703,9 +734,7 @@ describe("dispatchInterruptFinish produces correct AG-UI protocol events", () =>
);
expect(customEvents).toHaveLength(0);

const finished = events.find(
(e: any) => e.type === "RUN_FINISHED",
);
const finished = events.find((e: any) => e.type === "RUN_FINISHED");
expect(finished.outcome.type).toBe("interrupt");
});

Expand All @@ -721,9 +750,7 @@ describe("dispatchInterruptFinish produces correct AG-UI protocol events", () =>
lgInterrupts: [{ value: { reason: "r" }, id: "int-1" }],
});

const finished = events.find(
(e: any) => e.type === "RUN_FINISHED",
);
const finished = events.find((e: any) => e.type === "RUN_FINISHED");
expect(finished.outcome.type).toBe("interrupt");
expect(finished.outcome.interrupts).toHaveLength(1);
});
Expand Down Expand Up @@ -782,7 +809,9 @@ describe("prepareStream input.resume protocol", () => {
tools: [],
context: [],
forwardedProps: { command: { resume: "legacy_value" } },
resume: [{ interruptId: "i1", status: "resolved", payload: { new: true } }],
resume: [
{ interruptId: "i1", status: "resolved", payload: { new: true } },
],
};

(agent as any).client.threads.getState = vi.fn().mockResolvedValue({
Expand All @@ -798,7 +827,9 @@ describe("prepareStream input.resume protocol", () => {
]);

expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("both input.resume and forwardedProps.command.resume"),
expect.stringContaining(
"both input.resume and forwardedProps.command.resume",
),
);

const payload = capturedPayload.value!;
Expand Down Expand Up @@ -849,7 +880,9 @@ describe("prepareStream input.resume protocol", () => {
tools: [],
context: [],
forwardedProps: {},
resume: [{ interruptId: "i1", status: "resolved", payload: { approved: true } }],
resume: [
{ interruptId: "i1", status: "resolved", payload: { approved: true } },
],
};

(agent as any).client.threads.getState = vi.fn().mockResolvedValue({
Expand Down Expand Up @@ -925,9 +958,7 @@ describe("prepareStream input.resume protocol", () => {
"messages-tuple",
]);

const finished = events.find(
(e: any) => e.type === "RUN_FINISHED",
);
const finished = events.find((e: any) => e.type === "RUN_FINISHED");
expect(finished).toBeDefined();
expect(finished.outcome.type).toBe("interrupt");
expect(finished.outcome.interrupts).toHaveLength(1);
Expand Down
17 changes: 12 additions & 5 deletions integrations/langgraph/typescript/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,8 @@ export class LangGraphAgent extends AbstractAgent {
constructor(config: LangGraphAgentConfig) {
super(config);
this.config = config;
this.enableLegacyOnInterruptEvent = config.enableLegacyOnInterruptEvent ?? true;
this.enableLegacyOnInterruptEvent =
config.enableLegacyOnInterruptEvent ?? true;
this.emitInterruptOutcome = config.emitInterruptOutcome ?? false;
this.messagesInProcess = {};
this.agentName = config.agentName;
Expand Down Expand Up @@ -281,13 +282,16 @@ export class LangGraphAgent extends AbstractAgent {
client: this.client,
enableLegacyOnInterruptEvent: this.enableLegacyOnInterruptEvent,
emitInterruptOutcome: this.emitInterruptOutcome,
pendingInterrupts: structuredClone(this.pendingInterrupts ?? []),

assistant: this.assistant,
activeRun: this.activeRun ? structuredClone(this.activeRun) : undefined,
cancelRequested: this.cancelRequested,
cancelSent: this.cancelSent,
subgraphs: this.subgraphs ? new Set(this.subgraphs) : new Set(),
currentSubgraph: ROOT_SUBGRAPH_NAME,
emittedToolCallStartIds: new Set<string>(),
eventsStreamActive: false,
});

// Rebuild client so onRequest captures the cloned agent's headers
Expand Down Expand Up @@ -702,7 +706,10 @@ export class LangGraphAgent extends AbstractAgent {
openInterrupts: this.interruptsToAGUI(interrupts),
}),
};
} else if (effectiveCommand?.resume && typeof effectiveCommand.resume === "string") {
} else if (
effectiveCommand?.resume &&
typeof effectiveCommand.resume === "string"
) {
try {
effectiveCommand.resume = JSON.parse(effectiveCommand.resume);
} catch {
Expand Down Expand Up @@ -1773,7 +1780,8 @@ export class LangGraphAgent extends AbstractAgent {
// one: the snapshot converter re-emits this same reasoning under that
// id, and only a matching id lets the client reconcile the streamed
// copy with the snapshot copy instead of rendering both.
const messageId = reasoningData.id ?? this.pendingReasoningId ?? randomUUID();
const messageId =
reasoningData.id ?? this.pendingReasoningId ?? randomUUID();
this.pendingReasoningId = undefined;
this.dispatchEvent({
type: EventType.REASONING_START,
Expand Down Expand Up @@ -2116,8 +2124,7 @@ export class LangGraphAgent extends AbstractAgent {
* bubbles. See #1317.
*/
private getOrPinTextMessageId(fallbackId: string): string {
const messageId =
this.activeRun!.currentTextMessageId ?? fallbackId;
const messageId = this.activeRun!.currentTextMessageId ?? fallbackId;
this.activeRun!.currentTextMessageId = messageId;
return messageId;
}
Expand Down
70 changes: 70 additions & 0 deletions middlewares/a2a-middleware/src/__tests__/agent-clone.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { describe, expect, it, vi } from "vitest";
import { AbstractAgent, BaseEvent, RunAgentInput } from "@ag-ui/client";
import { Observable } from "rxjs";
import { A2AMiddlewareAgent } from "../index";

vi.mock("@a2a-js/sdk/client", () => {
class A2AClient {
url: string;

constructor(url: string) {
this.url = url;
}

getAgentCard = vi.fn(async () => ({
name: this.url,
description: `Card for ${this.url}`,
skills: [],
}));
}

return { A2AClient };
});

class CloneableAgent extends AbstractAgent {
cloneCount = 0;

run(_input: RunAgentInput): Observable<BaseEvent> {
return new Observable<BaseEvent>();
}

clone() {
this.cloneCount += 1;
return Object.assign(super.clone(), {
cloneCount: this.cloneCount,
});
}
}

describe("A2AMiddlewareAgent clone", () => {
it("preserves config and clones owned mutable containers", async () => {
const orchestrationAgent = new CloneableAgent({
description: "orchestrator",
});
const agent = new A2AMiddlewareAgent({
agentUrls: ["http://agent-a.example", "http://agent-b.example"],
instructions: "Route tasks to remote agents.",
orchestrationAgent,
description: "a2a middleware",
});

const cloned = agent.clone() as A2AMiddlewareAgent;

expect(cloned).toBeInstanceOf(A2AMiddlewareAgent);
expect(cloned).not.toBe(agent);
expect(cloned.description).toBe(agent.description);
expect(cloned.instructions).toBe(agent.instructions);
expect(cloned.agentClients).toEqual(agent.agentClients);
expect(cloned.agentClients).not.toBe(agent.agentClients);
expect(cloned.orchestrationAgent).toBeInstanceOf(CloneableAgent);
expect(cloned.orchestrationAgent).not.toBe(agent.orchestrationAgent);
expect(orchestrationAgent.cloneCount).toBe(1);

const originalCards = await agent.agentCards;
const clonedCards = await cloned.agentCards;

expect(cloned.agentCards).not.toBe(agent.agentCards);
expect(clonedCards).toEqual(originalCards);
expect(clonedCards).not.toBe(originalCards);
});
});
11 changes: 11 additions & 0 deletions middlewares/a2a-middleware/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,17 @@ export class A2AMiddlewareAgent extends AbstractAgent {
this.orchestrationAgent = config.orchestrationAgent;
}

public clone() {
const cloned = Object.assign(super.clone(), {
instructions: this.instructions,
agentClients: [...this.agentClients],
agentCards: this.agentCards.then((cards) => [...cards]),
orchestrationAgent: this.orchestrationAgent.clone(),
});

return cloned;
}

finishTextMessages(
observer: Subscriber<{
type: EventType;
Expand Down
Loading