Skip to content

Commit 8aafcbe

Browse files
marthakellyclaude
andcommitted
fix(agent): skip empty reasoning deltas and auto-close reasoning lifecycle
Two fixes for Anthropic model stalls (fixes CopilotKit#3323): 1. Skip reasoning-delta events with empty text — EventSchemas.parse() rejects delta: "" and kills the RxJS Observable before any other events can fire. 2. Auto-close open reasoning lifecycle at every phase transition — @ai-sdk/anthropic never emits reasoning-end, leaving the state machine stuck. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent ecf6747 commit 8aafcbe

3 files changed

Lines changed: 187 additions & 4 deletions

File tree

.changeset/fix-reasoning-stall.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@copilotkitnext/agent": patch
3+
---
4+
5+
fix(agent): skip empty reasoning deltas and auto-close reasoning lifecycle when SDK omits reasoning-end

packages/runtime/src/agent/__tests__/basic-agent.test.ts

Lines changed: 151 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1047,10 +1047,11 @@ describe("BasicAgent", () => {
10471047

10481048
const events = await collectEvents(agent["run"](input));
10491049

1050-
const contentEvent = events.find(
1050+
// Empty delta must NOT be emitted — EventSchemas rejects delta: ""
1051+
const contentEvents = events.filter(
10511052
(e: any) => e.type === EventType.REASONING_MESSAGE_CONTENT,
10521053
);
1053-
expect(contentEvent).toMatchObject({ delta: "" });
1054+
expect(contentEvents).toHaveLength(0);
10541055

10551056
// Full lifecycle should still complete
10561057
const eventTypes = events.map((e: any) => e.type);
@@ -1102,6 +1103,154 @@ describe("BasicAgent", () => {
11021103
});
11031104
});
11041105

1106+
it("should skip empty reasoning deltas and continue stream", async () => {
1107+
const agent = new BasicAgent({
1108+
model: "openai/gpt-4o",
1109+
});
1110+
1111+
vi.mocked(streamText).mockReturnValue(
1112+
mockStreamTextResponse([
1113+
reasoningStart(),
1114+
reasoningDelta(""),
1115+
reasoningEnd(),
1116+
finish(),
1117+
]) as any,
1118+
);
1119+
1120+
const input: RunAgentInput = {
1121+
threadId: "thread1",
1122+
runId: "run1",
1123+
messages: [],
1124+
tools: [],
1125+
context: [],
1126+
state: {},
1127+
};
1128+
1129+
const events = await collectEvents(agent["run"](input));
1130+
1131+
// No REASONING_MESSAGE_CONTENT events — empty delta skipped
1132+
const contentEvents = events.filter(
1133+
(e: any) => e.type === EventType.REASONING_MESSAGE_CONTENT,
1134+
);
1135+
expect(contentEvents).toHaveLength(0);
1136+
1137+
// Stream still completes with RUN_FINISHED
1138+
const eventTypes = events.map((e: any) => e.type);
1139+
expect(eventTypes[eventTypes.length - 1]).toBe(EventType.RUN_FINISHED);
1140+
});
1141+
1142+
it("should auto-close reasoning when SDK omits reasoning-end before tool call", async () => {
1143+
const agent = new BasicAgent({
1144+
model: "openai/gpt-4o",
1145+
});
1146+
1147+
vi.mocked(streamText).mockReturnValue(
1148+
mockStreamTextResponse([
1149+
reasoningStart(),
1150+
reasoningDelta("Thinking..."),
1151+
// NO reasoningEnd() — simulates @ai-sdk/anthropic behaviour
1152+
toolCallStreamingStart("call1", "testTool"),
1153+
toolCallDelta("call1", '{"arg":"val"}'),
1154+
toolCall("call1", "testTool", { arg: "val" }),
1155+
toolResult("call1", "testTool", { result: "success" }),
1156+
finish(),
1157+
]) as any,
1158+
);
1159+
1160+
const input: RunAgentInput = {
1161+
threadId: "thread1",
1162+
runId: "run1",
1163+
messages: [],
1164+
tools: [],
1165+
context: [],
1166+
state: {},
1167+
};
1168+
1169+
const events = await collectEvents(agent["run"](input));
1170+
const eventTypes = events.map((e: any) => e.type);
1171+
1172+
// REASONING_END must appear before TOOL_CALL_START
1173+
const reasoningEndIdx = eventTypes.indexOf(EventType.REASONING_END);
1174+
const toolCallStartIdx = eventTypes.indexOf(EventType.TOOL_CALL_START);
1175+
expect(reasoningEndIdx).toBeGreaterThan(0);
1176+
expect(reasoningEndIdx).toBeLessThan(toolCallStartIdx);
1177+
1178+
// Stream still completes with RUN_FINISHED
1179+
expect(eventTypes[eventTypes.length - 1]).toBe(EventType.RUN_FINISHED);
1180+
});
1181+
1182+
it("should auto-close reasoning when SDK omits reasoning-end before text", async () => {
1183+
const agent = new BasicAgent({
1184+
model: "openai/gpt-4o",
1185+
});
1186+
1187+
vi.mocked(streamText).mockReturnValue(
1188+
mockStreamTextResponse([
1189+
reasoningStart(),
1190+
reasoningDelta("Let me think"),
1191+
// NO reasoningEnd() — simulates @ai-sdk/anthropic behaviour
1192+
textStart(),
1193+
textDelta("Answer"),
1194+
finish(),
1195+
]) as any,
1196+
);
1197+
1198+
const input: RunAgentInput = {
1199+
threadId: "thread1",
1200+
runId: "run1",
1201+
messages: [],
1202+
tools: [],
1203+
context: [],
1204+
state: {},
1205+
};
1206+
1207+
const events = await collectEvents(agent["run"](input));
1208+
const eventTypes = events.map((e: any) => e.type);
1209+
1210+
// REASONING_END must appear before TEXT_MESSAGE_CHUNK
1211+
const reasoningEndIdx = eventTypes.indexOf(EventType.REASONING_END);
1212+
const textChunkIdx = eventTypes.indexOf(EventType.TEXT_MESSAGE_CHUNK);
1213+
expect(reasoningEndIdx).toBeGreaterThan(0);
1214+
expect(reasoningEndIdx).toBeLessThan(textChunkIdx);
1215+
1216+
// Stream still completes with RUN_FINISHED
1217+
expect(eventTypes[eventTypes.length - 1]).toBe(EventType.RUN_FINISHED);
1218+
});
1219+
1220+
it("should auto-close reasoning when SDK omits reasoning-end before finish", async () => {
1221+
const agent = new BasicAgent({
1222+
model: "openai/gpt-4o",
1223+
});
1224+
1225+
vi.mocked(streamText).mockReturnValue(
1226+
mockStreamTextResponse([
1227+
reasoningStart(),
1228+
reasoningDelta("Deep thought"),
1229+
// NO reasoningEnd() — simulates @ai-sdk/anthropic behaviour
1230+
finish(),
1231+
]) as any,
1232+
);
1233+
1234+
const input: RunAgentInput = {
1235+
threadId: "thread1",
1236+
runId: "run1",
1237+
messages: [],
1238+
tools: [],
1239+
context: [],
1240+
state: {},
1241+
};
1242+
1243+
const events = await collectEvents(agent["run"](input));
1244+
const eventTypes = events.map((e: any) => e.type);
1245+
1246+
// REASONING_END must be emitted (auto-closed by finish case)
1247+
expect(eventTypes).toContain(EventType.REASONING_END);
1248+
expect(eventTypes).toContain(EventType.REASONING_MESSAGE_END);
1249+
1250+
// Stream still completes with RUN_FINISHED
1251+
expect(eventTypes[eventTypes.length - 1]).toBe(EventType.RUN_FINISHED);
1252+
});
1253+
11051254
it("should handle reasoning interleaved with tool calls", async () => {
11061255
const agent = new BasicAgent({
11071256
model: "openai/gpt-4o",

packages/runtime/src/agent/index.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -967,6 +967,25 @@ export class BuiltInAgent extends AbstractAgent {
967967

968968
let messageId = randomUUID();
969969
let reasoningMessageId = randomUUID();
970+
let isInReasoning = false;
971+
972+
// Auto-close an open reasoning lifecycle.
973+
// Some AI SDK providers (notably @ai-sdk/anthropic) never emit "reasoning-end",
974+
// which leaves downstream state machines stuck. This helper emits the
975+
// missing REASONING_MESSAGE_END + REASONING_END events so the stream
976+
// can transition to text, tool-call, or finish phases.
977+
const closeReasoningIfOpen = () => {
978+
if (!isInReasoning) return;
979+
isInReasoning = false;
980+
subscriber.next({
981+
type: EventType.REASONING_MESSAGE_END,
982+
messageId: reasoningMessageId,
983+
} as ReasoningMessageEndEvent);
984+
subscriber.next({
985+
type: EventType.REASONING_END,
986+
messageId: reasoningMessageId,
987+
} as ReasoningEndEvent);
988+
};
970989

971990
const toolCallStates = new Map<
972991
string,
@@ -991,6 +1010,7 @@ export class BuiltInAgent extends AbstractAgent {
9911010
for await (const part of response.fullStream) {
9921011
switch (part.type) {
9931012
case "abort": {
1013+
closeReasoningIfOpen();
9941014
const abortEndEvent: RunFinishedEvent = {
9951015
type: EventType.RUN_FINISHED,
9961016
threadId: input.threadId,
@@ -1021,19 +1041,23 @@ export class BuiltInAgent extends AbstractAgent {
10211041
role: "reasoning",
10221042
};
10231043
subscriber.next(reasoningMessageStart);
1044+
isInReasoning = true;
10241045
break;
10251046
}
10261047
case "reasoning-delta": {
1048+
const delta =
1049+
("text" in part ? part.text : (part as any).delta) ?? "";
1050+
if (!delta) break; // skip — EventSchemas rejects delta: ""
10271051
const reasoningDeltaEvent: ReasoningMessageContentEvent = {
10281052
type: EventType.REASONING_MESSAGE_CONTENT,
10291053
messageId: reasoningMessageId,
1030-
delta:
1031-
("text" in part ? part.text : (part as any).delta) ?? "",
1054+
delta,
10321055
};
10331056
subscriber.next(reasoningDeltaEvent);
10341057
break;
10351058
}
10361059
case "reasoning-end": {
1060+
isInReasoning = false;
10371061
const reasoningMessageEnd: ReasoningMessageEndEvent = {
10381062
type: EventType.REASONING_MESSAGE_END,
10391063
messageId: reasoningMessageId,
@@ -1047,6 +1071,7 @@ export class BuiltInAgent extends AbstractAgent {
10471071
break;
10481072
}
10491073
case "tool-input-start": {
1074+
closeReasoningIfOpen();
10501075
const toolCallId = part.id;
10511076
const state = ensureToolCallState(toolCallId);
10521077
state.toolName = part.toolName;
@@ -1082,6 +1107,7 @@ export class BuiltInAgent extends AbstractAgent {
10821107
}
10831108

10841109
case "text-start": {
1110+
closeReasoningIfOpen();
10851111
// New text message starting - use the SDK-provided id
10861112
// Use randomUUID() if part.id is falsy or "0" to prevent message merging issues
10871113
const providedId = "id" in part ? part.id : undefined;
@@ -1107,6 +1133,7 @@ export class BuiltInAgent extends AbstractAgent {
11071133
}
11081134

11091135
case "tool-call": {
1136+
closeReasoningIfOpen();
11101137
const toolCallId = part.toolCallId;
11111138
const state = ensureToolCallState(toolCallId);
11121139
state.toolName = part.toolName ?? state.toolName;
@@ -1203,6 +1230,7 @@ export class BuiltInAgent extends AbstractAgent {
12031230
}
12041231

12051232
case "finish": {
1233+
closeReasoningIfOpen();
12061234
// Emit run finished event
12071235
const finishedEvent: RunFinishedEvent = {
12081236
type: EventType.RUN_FINISHED,
@@ -1218,6 +1246,7 @@ export class BuiltInAgent extends AbstractAgent {
12181246
}
12191247

12201248
case "error": {
1249+
closeReasoningIfOpen();
12211250
if (abortController.signal.aborted) {
12221251
break;
12231252
}

0 commit comments

Comments
 (0)