Skip to content

Commit fbf67ce

Browse files
authored
fix: replace non-unique provider IDs like txt-0 with UUIDs (CopilotKit#3410, CopilotKit#3623) (CopilotKit#3800)
## Summary - Providers like `@ai-sdk/openai-compatible` emit sequential IDs (`txt-0`, `reasoning-0`, `msg-0`) that are treated as unique but collide across requests - The existing check only caught the literal `"0"` — expanded to match the `^(txt|reasoning|msg)-0$` pattern - Both `text-start` and `reasoning-start` event handlers now use `randomUUID()` for these non-unique IDs ## Test plan - [x] Added `provider-id-collision.test.ts` with 4 tests covering txt-0, reasoning-0, msg-0, and legitimate ID preservation - [x] All 1226 runtime tests pass (including existing config-tools-execution tests) Fixes CopilotKit#3410, CopilotKit#3623
2 parents 078b840 + e121937 commit fbf67ce

2 files changed

Lines changed: 214 additions & 11 deletions

File tree

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2+
import { BuiltInAgent } from "../index";
3+
import { EventType, type RunAgentInput } from "@ag-ui/client";
4+
import { streamText } from "ai";
5+
import {
6+
mockStreamTextResponse,
7+
textDelta,
8+
finish,
9+
collectEvents,
10+
} from "./test-helpers";
11+
12+
// Mock the ai module
13+
vi.mock("ai", () => ({
14+
streamText: vi.fn(),
15+
tool: vi.fn((config) => config),
16+
stepCountIs: vi.fn((count: number) => ({ type: "stepCount", count })),
17+
}));
18+
19+
vi.mock("@ai-sdk/openai", () => ({
20+
createOpenAI: vi.fn(() => (modelId: string) => ({
21+
specificationVersion: "v3",
22+
modelId,
23+
provider: "openai",
24+
})),
25+
}));
26+
27+
vi.mock("@ai-sdk/anthropic", () => ({
28+
createAnthropic: vi.fn(() => (modelId: string) => ({
29+
specificationVersion: "v3",
30+
modelId,
31+
provider: "anthropic",
32+
})),
33+
}));
34+
35+
vi.mock("@ai-sdk/google", () => ({
36+
createGoogleGenerativeAI: vi.fn(() => (modelId: string) => ({
37+
specificationVersion: "v3",
38+
modelId,
39+
provider: "google",
40+
})),
41+
}));
42+
43+
describe("Provider ID collision (#3410, #3623)", () => {
44+
const originalEnv = process.env;
45+
46+
beforeEach(() => {
47+
vi.clearAllMocks();
48+
process.env = { ...originalEnv };
49+
process.env.OPENAI_API_KEY = "test-key";
50+
});
51+
52+
afterEach(() => {
53+
process.env = originalEnv;
54+
});
55+
56+
it('should replace text-start providedId "txt-0" with a UUID', async () => {
57+
const agent = new BuiltInAgent({ model: "openai:gpt-4o-mini" });
58+
59+
vi.mocked(streamText).mockReturnValue(
60+
mockStreamTextResponse([
61+
{ type: "text-start", id: "txt-0" },
62+
textDelta("Hello"),
63+
finish(),
64+
]) as any,
65+
);
66+
67+
const input: RunAgentInput = {
68+
threadId: "thread-1",
69+
runId: "run-1",
70+
messages: [{ id: "1", role: "user", content: "Hi" }],
71+
tools: [],
72+
context: [],
73+
state: {},
74+
};
75+
76+
const events = await collectEvents(agent["run"](input));
77+
78+
// Find the TEXT_MESSAGE_CHUNK event and check its messageId
79+
const textChunks = events.filter(
80+
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
81+
);
82+
expect(textChunks.length).toBeGreaterThan(0);
83+
const messageId = (textChunks[0] as any).messageId;
84+
85+
// The messageId should NOT be "txt-0" — it should be a UUID
86+
expect(messageId).not.toBe("txt-0");
87+
// UUID v4 pattern
88+
expect(messageId).toMatch(
89+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
90+
);
91+
});
92+
93+
it('should replace reasoning-start providedId "reasoning-0" with a UUID', async () => {
94+
const agent = new BuiltInAgent({ model: "openai:gpt-4o-mini" });
95+
96+
vi.mocked(streamText).mockReturnValue(
97+
mockStreamTextResponse([
98+
{ type: "reasoning-start", id: "reasoning-0" },
99+
{ type: "reasoning-delta", text: "Thinking..." },
100+
{ type: "reasoning-end" },
101+
{ type: "text-start", id: "txt-0" },
102+
textDelta("Answer"),
103+
finish(),
104+
]) as any,
105+
);
106+
107+
const input: RunAgentInput = {
108+
threadId: "thread-2",
109+
runId: "run-2",
110+
messages: [{ id: "1", role: "user", content: "Hi" }],
111+
tools: [],
112+
context: [],
113+
state: {},
114+
};
115+
116+
const events = await collectEvents(agent["run"](input));
117+
118+
// Find REASONING_START event
119+
const reasoningStarts = events.filter(
120+
(e) => e.type === EventType.REASONING_START,
121+
);
122+
expect(reasoningStarts.length).toBeGreaterThan(0);
123+
const reasoningId = (reasoningStarts[0] as any).messageId;
124+
125+
// Should NOT be "reasoning-0"
126+
expect(reasoningId).not.toBe("reasoning-0");
127+
expect(reasoningId).toMatch(
128+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
129+
);
130+
});
131+
132+
it('should replace providedId "msg-0" with a UUID', async () => {
133+
const agent = new BuiltInAgent({ model: "openai:gpt-4o-mini" });
134+
135+
vi.mocked(streamText).mockReturnValue(
136+
mockStreamTextResponse([
137+
{ type: "text-start", id: "msg-0" },
138+
textDelta("Hello"),
139+
finish(),
140+
]) as any,
141+
);
142+
143+
const input: RunAgentInput = {
144+
threadId: "thread-3",
145+
runId: "run-3",
146+
messages: [{ id: "1", role: "user", content: "Hi" }],
147+
tools: [],
148+
context: [],
149+
state: {},
150+
};
151+
152+
const events = await collectEvents(agent["run"](input));
153+
154+
const textChunks = events.filter(
155+
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
156+
);
157+
expect(textChunks.length).toBeGreaterThan(0);
158+
const messageId = (textChunks[0] as any).messageId;
159+
160+
expect(messageId).not.toBe("msg-0");
161+
expect(messageId).toMatch(
162+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
163+
);
164+
});
165+
166+
it("should preserve legitimate provider IDs", async () => {
167+
const agent = new BuiltInAgent({ model: "openai:gpt-4o-mini" });
168+
169+
vi.mocked(streamText).mockReturnValue(
170+
mockStreamTextResponse([
171+
{ type: "text-start", id: "custom-msg-id-123" },
172+
textDelta("Hello"),
173+
finish(),
174+
]) as any,
175+
);
176+
177+
const input: RunAgentInput = {
178+
threadId: "thread-4",
179+
runId: "run-4",
180+
messages: [{ id: "1", role: "user", content: "Hi" }],
181+
tools: [],
182+
context: [],
183+
state: {},
184+
};
185+
186+
const events = await collectEvents(agent["run"](input));
187+
188+
const textChunks = events.filter(
189+
(e) => e.type === EventType.TEXT_MESSAGE_CHUNK,
190+
);
191+
expect(textChunks.length).toBeGreaterThan(0);
192+
// Legitimate IDs should be preserved
193+
expect((textChunks[0] as any).messageId).toBe("custom-msg-id-123");
194+
});
195+
});

packages/runtime/src/agent/index.ts

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1267,13 +1267,17 @@ export class BuiltInAgent extends AbstractAgent {
12671267
break;
12681268
}
12691269
case "reasoning-start": {
1270-
// Use SDK-provided id, or generate a fresh UUID if id is falsy/"0"
1271-
// to prevent consecutive reasoning blocks from sharing a messageId
1270+
// Use SDK-provided id, or generate a fresh UUID if the id is falsy,
1271+
// "0", or matches the non-unique pattern emitted by @ai-sdk/openai-compatible
1272+
// (e.g. "txt-0", "reasoning-0", "msg-0").
12721273
const providedId = "id" in part ? part.id : undefined;
1273-
reasoningMessageId =
1274-
providedId && providedId !== "0"
1275-
? (providedId as typeof reasoningMessageId)
1276-
: randomUUID();
1274+
const isNonUniqueId =
1275+
!providedId ||
1276+
providedId === "0" ||
1277+
/^(txt|reasoning|msg)-0$/.test(providedId);
1278+
reasoningMessageId = isNonUniqueId
1279+
? randomUUID()
1280+
: (providedId as typeof reasoningMessageId);
12771281
const reasoningStartEvent: ReasoningStartEvent = {
12781282
type: EventType.REASONING_START,
12791283
messageId: reasoningMessageId,
@@ -1341,12 +1345,16 @@ export class BuiltInAgent extends AbstractAgent {
13411345

13421346
case "text-start": {
13431347
// New text message starting - use the SDK-provided id
1344-
// Use randomUUID() if part.id is falsy or "0" to prevent message merging issues
1348+
// Use randomUUID() if part.id is falsy, "0", or matches the non-unique
1349+
// pattern emitted by @ai-sdk/openai-compatible (e.g. "txt-0", "msg-0").
13451350
const providedId = "id" in part ? part.id : undefined;
1346-
messageId =
1347-
providedId && providedId !== "0"
1348-
? (providedId as typeof messageId)
1349-
: randomUUID();
1351+
const isNonUniqueTextId =
1352+
!providedId ||
1353+
providedId === "0" ||
1354+
/^(txt|reasoning|msg)-0$/.test(providedId);
1355+
messageId = isNonUniqueTextId
1356+
? randomUUID()
1357+
: (providedId as typeof messageId);
13501358
break;
13511359
}
13521360

0 commit comments

Comments
 (0)