Skip to content

Commit 9060f2a

Browse files
marthakellyclaude
andcommitted
fix(runtime): address code review feedback on reasoning lifecycle fix
- Call closeReasoningIfOpen() once before the switch instead of in every case branch - Remove as-casts from closeReasoningIfOpen by using typed const variables - Remove as-casts from new test code; use typed imports and unknown-safe casts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 5aa901f commit 9060f2a

3 files changed

Lines changed: 67 additions & 57 deletions

File tree

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

Lines changed: 35 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
22
import { z } from "zod";
33
import { BasicAgent, defineTool, type ToolDefinition } from "../index";
4-
import { EventType, type RunAgentInput } from "@ag-ui/client";
4+
import {
5+
EventType,
6+
type BaseEvent,
7+
type ReasoningStartEvent,
8+
type RunAgentInput,
9+
} from "@ag-ui/client";
510
import { streamText } from "ai";
611
import {
712
mockStreamTextResponse,
@@ -1075,7 +1080,7 @@ describe("BasicAgent", () => {
10751080
reasoningDelta("Deep thought"),
10761081
reasoningEnd(),
10771082
finish(),
1078-
]) as any,
1083+
]),
10791084
);
10801085

10811086
const input: RunAgentInput = {
@@ -1116,7 +1121,7 @@ describe("BasicAgent", () => {
11161121
reasoningDelta(""),
11171122
reasoningEnd(),
11181123
finish(),
1119-
]) as any,
1124+
]),
11201125
);
11211126

11221127
const input: RunAgentInput = {
@@ -1132,12 +1137,12 @@ describe("BasicAgent", () => {
11321137

11331138
// No REASONING_MESSAGE_CONTENT events — empty delta skipped
11341139
const contentEvents = events.filter(
1135-
(e: any) => e.type === EventType.REASONING_MESSAGE_CONTENT,
1140+
(e) => e.type === EventType.REASONING_MESSAGE_CONTENT,
11361141
);
11371142
expect(contentEvents).toHaveLength(0);
11381143

11391144
// Stream still completes with RUN_FINISHED
1140-
const eventTypes = events.map((e: any) => e.type);
1145+
const eventTypes = events.map((e) => e.type);
11411146
expect(eventTypes[eventTypes.length - 1]).toBe(EventType.RUN_FINISHED);
11421147
});
11431148

@@ -1156,7 +1161,7 @@ describe("BasicAgent", () => {
11561161
toolCall("call1", "testTool", { arg: "val" }),
11571162
toolResult("call1", "testTool", { result: "success" }),
11581163
finish(),
1159-
]) as any,
1164+
]),
11601165
);
11611166

11621167
const input: RunAgentInput = {
@@ -1169,7 +1174,7 @@ describe("BasicAgent", () => {
11691174
};
11701175

11711176
const events = await collectEvents(agent["run"](input));
1172-
const eventTypes = events.map((e: any) => e.type);
1177+
const eventTypes = events.map((e) => e.type);
11731178

11741179
// REASONING_MESSAGE_END must appear before REASONING_END, which must appear before TOOL_CALL_START
11751180
const reasoningMsgEndIdx = eventTypes.indexOf(
@@ -1206,7 +1211,7 @@ describe("BasicAgent", () => {
12061211
textStart(),
12071212
textDelta("Answer"),
12081213
finish(),
1209-
]) as any,
1214+
]),
12101215
);
12111216

12121217
const input: RunAgentInput = {
@@ -1219,7 +1224,7 @@ describe("BasicAgent", () => {
12191224
};
12201225

12211226
const events = await collectEvents(agent["run"](input));
1222-
const eventTypes = events.map((e: any) => e.type);
1227+
const eventTypes = events.map((e) => e.type);
12231228

12241229
// REASONING_MESSAGE_END must appear before REASONING_END, which must appear before TEXT_MESSAGE_CHUNK
12251230
const reasoningMsgEndIdx = eventTypes.indexOf(
@@ -1254,7 +1259,7 @@ describe("BasicAgent", () => {
12541259
reasoningDelta("Deep thought"),
12551260
// NO reasoningEnd() — simulates @ai-sdk/anthropic behaviour
12561261
finish(),
1257-
]) as any,
1262+
]),
12581263
);
12591264

12601265
const input: RunAgentInput = {
@@ -1267,7 +1272,7 @@ describe("BasicAgent", () => {
12671272
};
12681273

12691274
const events = await collectEvents(agent["run"](input));
1270-
const eventTypes = events.map((e: any) => e.type);
1275+
const eventTypes = events.map((e) => e.type);
12711276

12721277
// REASONING_MESSAGE_END must appear before REASONING_END (auto-closed by finish case)
12731278
const reasoningMsgEndIdx = eventTypes.indexOf(
@@ -1300,7 +1305,7 @@ describe("BasicAgent", () => {
13001305
reasoningDelta("Thinking..."),
13011306
// NO reasoningEnd() — stream aborts before SDK can close reasoning
13021307
abort(),
1303-
]) as any,
1308+
]),
13041309
);
13051310

13061311
const input: RunAgentInput = {
@@ -1313,7 +1318,7 @@ describe("BasicAgent", () => {
13131318
};
13141319

13151320
const events = await collectEvents(agent["run"](input));
1316-
const eventTypes = events.map((e: any) => e.type);
1321+
const eventTypes = events.map((e) => e.type);
13171322

13181323
// REASONING_MESSAGE_END must appear before REASONING_END, both before RUN_FINISHED
13191324
const reasoningMsgEndIdx = eventTypes.indexOf(
@@ -1348,7 +1353,7 @@ describe("BasicAgent", () => {
13481353
reasoningDelta("Thinking..."),
13491354
// NO reasoningEnd() — stream errors before SDK can close reasoning
13501355
error("stream failed"),
1351-
]) as any,
1356+
]),
13521357
);
13531358

13541359
const input: RunAgentInput = {
@@ -1361,16 +1366,16 @@ describe("BasicAgent", () => {
13611366
};
13621367

13631368
// subscriber.error() causes collectEvents to reject, so collect manually
1364-
const events: any[] = [];
1369+
const events: BaseEvent[] = [];
13651370
await new Promise<void>((resolve) => {
13661371
agent["run"](input).subscribe({
1367-
next: (e: any) => events.push(e),
1372+
next: (e) => events.push(e),
13681373
error: () => resolve(), // error is expected
13691374
complete: () => resolve(),
13701375
});
13711376
});
13721377

1373-
const eventTypes = events.map((e: any) => e.type);
1378+
const eventTypes = events.map((e) => e.type);
13741379

13751380
// REASONING_MESSAGE_END must appear before REASONING_END, both before RUN_ERROR
13761381
const reasoningMsgEndIdx = eventTypes.indexOf(
@@ -1405,7 +1410,7 @@ describe("BasicAgent", () => {
14051410
reasoningDelta("Second thought"),
14061411
reasoningEnd(),
14071412
finish(),
1408-
]) as any,
1413+
]),
14091414
);
14101415

14111416
const input: RunAgentInput = {
@@ -1418,7 +1423,7 @@ describe("BasicAgent", () => {
14181423
};
14191424

14201425
const events = await collectEvents(agent["run"](input));
1421-
const eventTypes = events.map((e: any) => e.type);
1426+
const eventTypes = events.map((e) => e.type);
14221427

14231428
// Both reasoning blocks must be properly closed — two complete lifecycles
14241429
expect(
@@ -1437,12 +1442,10 @@ describe("BasicAgent", () => {
14371442

14381443
// The two blocks must use distinct messageIds
14391444
const startEvents = events.filter(
1440-
(e: any) => e.type === EventType.REASONING_START,
1445+
(e): e is ReasoningStartEvent => e.type === EventType.REASONING_START,
14411446
);
14421447
expect(startEvents).toHaveLength(2);
1443-
expect((startEvents[0] as any).messageId).not.toBe(
1444-
(startEvents[1] as any).messageId,
1445-
);
1448+
expect(startEvents[0].messageId).not.toBe(startEvents[1].messageId);
14461449

14471450
expect(eventTypes[eventTypes.length - 1]).toBe(EventType.RUN_FINISHED);
14481451
});
@@ -1460,7 +1463,9 @@ describe("BasicAgent", () => {
14601463
throw new Error("unexpected network failure");
14611464
})(),
14621465
};
1463-
vi.mocked(streamText).mockReturnValue(throwingStream as any);
1466+
vi.mocked(streamText).mockReturnValue(
1467+
throwingStream as unknown as ReturnType<typeof streamText>,
1468+
);
14641469

14651470
const input: RunAgentInput = {
14661471
threadId: "thread1",
@@ -1472,16 +1477,16 @@ describe("BasicAgent", () => {
14721477
};
14731478

14741479
// subscriber.error() causes collectEvents to reject, so collect manually
1475-
const events: any[] = [];
1480+
const events: BaseEvent[] = [];
14761481
await new Promise<void>((resolve) => {
14771482
agent["run"](input).subscribe({
1478-
next: (e: any) => events.push(e),
1483+
next: (e) => events.push(e),
14791484
error: () => resolve(), // error is expected
14801485
complete: () => resolve(),
14811486
});
14821487
});
14831488

1484-
const eventTypes = events.map((e: any) => e.type);
1489+
const eventTypes = events.map((e) => e.type);
14851490

14861491
// Reasoning must be closed before RUN_ERROR despite the exception path
14871492
const reasoningMsgEndIdx = eventTypes.indexOf(
@@ -1512,7 +1517,7 @@ describe("BasicAgent", () => {
15121517
reasoningStart(),
15131518
reasoningDelta("Thinking..."),
15141519
// deliberate: no finish(), no abort(), no error()
1515-
]) as any,
1520+
]),
15161521
);
15171522

15181523
const input: RunAgentInput = {
@@ -1525,7 +1530,7 @@ describe("BasicAgent", () => {
15251530
};
15261531

15271532
const events = await collectEvents(agent["run"](input));
1528-
const eventTypes = events.map((e: any) => e.type);
1533+
const eventTypes = events.map((e) => e.type);
15291534

15301535
// Reasoning must be closed before RUN_FINISHED via fallback
15311536
const reasoningMsgEndIdx = eventTypes.indexOf(
@@ -1575,7 +1580,7 @@ describe("BasicAgent", () => {
15751580
};
15761581

15771582
const events = await collectEvents(agent["run"](input));
1578-
const eventTypes = events.map((e: any) => e.type);
1583+
const eventTypes = events.map((e) => e.type);
15791584

15801585
// Reasoning events precede tool call events
15811586
const reasoningEndIdx = eventTypes.indexOf(EventType.REASONING_END);

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

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,28 @@
22
* Test helpers for mocking streamText responses
33
*/
44

5+
import type { streamText } from "ai";
6+
import type { Observable } from "rxjs";
7+
import type { BaseEvent } from "@ag-ui/client";
8+
59
export interface MockStreamEvent {
610
type: string;
7-
[key: string]: any;
11+
[key: string]: unknown;
812
}
913

1014
/**
11-
* Creates a mock streamText response with controlled events
15+
* Creates a mock streamText response with controlled events.
1216
*/
13-
export function mockStreamTextResponse(events: MockStreamEvent[]) {
17+
export function mockStreamTextResponse(
18+
events: MockStreamEvent[],
19+
): ReturnType<typeof streamText> {
1420
return {
1521
fullStream: (async function* () {
1622
for (const event of events) {
1723
yield event;
1824
}
1925
})(),
20-
};
26+
} as unknown as ReturnType<typeof streamText>;
2127
}
2228

2329
/**
@@ -93,7 +99,7 @@ export function toolCall(
9399
export function toolResult(
94100
toolCallId: string,
95101
toolName: string,
96-
output: any,
102+
output: unknown,
97103
): MockStreamEvent {
98104
return {
99105
type: "tool-result",
@@ -165,16 +171,16 @@ export function error(errorMessage: string): MockStreamEvent {
165171
}
166172

167173
/**
168-
* Collects all events from an Observable into an array
174+
* Collects all events from an Observable<BaseEvent> into an array.
169175
*/
170-
export async function collectEvents<T>(observable: {
171-
subscribe: (observer: any) => any;
172-
}): Promise<T[]> {
176+
export async function collectEvents(
177+
observable: Observable<BaseEvent>,
178+
): Promise<BaseEvent[]> {
173179
return new Promise((resolve, reject) => {
174-
const events: T[] = [];
180+
const events: BaseEvent[] = [];
175181
const subscription = observable.subscribe({
176-
next: (event: T) => events.push(event),
177-
error: (err: any) => reject(err),
182+
next: (event) => events.push(event),
183+
error: (err: unknown) => reject(err),
178184
complete: () => resolve(events),
179185
});
180186

packages/runtime/src/agent/index.ts

Lines changed: 14 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -885,14 +885,16 @@ export class BuiltInAgent extends AbstractAgent {
885885
const closeReasoningIfOpen = () => {
886886
if (!isInReasoning) return;
887887
isInReasoning = false;
888-
subscriber.next({
888+
const reasoningMsgEnd: ReasoningMessageEndEvent = {
889889
type: EventType.REASONING_MESSAGE_END,
890890
messageId: reasoningMessageId,
891-
} as ReasoningMessageEndEvent);
892-
subscriber.next({
891+
};
892+
subscriber.next(reasoningMsgEnd);
893+
const reasoningEnd: ReasoningEndEvent = {
893894
type: EventType.REASONING_END,
894895
messageId: reasoningMessageId,
895-
} as ReasoningEndEvent);
896+
};
897+
subscriber.next(reasoningEnd);
896898
};
897899

898900
try {
@@ -1008,9 +1010,14 @@ export class BuiltInAgent extends AbstractAgent {
10081010

10091011
// Process fullStream events
10101012
for await (const part of response.fullStream) {
1013+
// Close any open reasoning lifecycle on every event except
1014+
// reasoning-delta, which arrives mid-block and must not interrupt it.
1015+
if (part.type !== "reasoning-delta") {
1016+
closeReasoningIfOpen();
1017+
}
1018+
10111019
switch (part.type) {
10121020
case "abort": {
1013-
closeReasoningIfOpen();
10141021
const abortEndEvent: RunFinishedEvent = {
10151022
type: EventType.RUN_FINISHED,
10161023
threadId: input.threadId,
@@ -1024,7 +1031,6 @@ export class BuiltInAgent extends AbstractAgent {
10241031
break;
10251032
}
10261033
case "reasoning-start": {
1027-
closeReasoningIfOpen();
10281034
// Use SDK-provided id, or generate a fresh UUID if id is falsy/"0"
10291035
// to prevent consecutive reasoning blocks from sharing a messageId
10301036
const providedId = "id" in part ? part.id : undefined;
@@ -1058,14 +1064,11 @@ export class BuiltInAgent extends AbstractAgent {
10581064
break;
10591065
}
10601066
case "reasoning-end": {
1061-
// Use closeReasoningIfOpen so this is idempotent: if a phase-transition
1062-
// case already auto-closed reasoning, a late reasoning-end from the SDK
1063-
// becomes a no-op instead of emitting duplicate end events.
1064-
closeReasoningIfOpen();
1067+
// closeReasoningIfOpen() already called before the switch — no-op here
1068+
// if the SDK never emits this event (e.g. @ai-sdk/anthropic).
10651069
break;
10661070
}
10671071
case "tool-input-start": {
1068-
closeReasoningIfOpen();
10691072
const toolCallId = part.id;
10701073
const state = ensureToolCallState(toolCallId);
10711074
state.toolName = part.toolName;
@@ -1101,7 +1104,6 @@ export class BuiltInAgent extends AbstractAgent {
11011104
}
11021105

11031106
case "text-start": {
1104-
closeReasoningIfOpen();
11051107
// New text message starting - use the SDK-provided id
11061108
// Use randomUUID() if part.id is falsy or "0" to prevent message merging issues
11071109
const providedId = "id" in part ? part.id : undefined;
@@ -1127,7 +1129,6 @@ export class BuiltInAgent extends AbstractAgent {
11271129
}
11281130

11291131
case "tool-call": {
1130-
closeReasoningIfOpen();
11311132
const toolCallId = part.toolCallId;
11321133
const state = ensureToolCallState(toolCallId);
11331134
state.toolName = part.toolName ?? state.toolName;
@@ -1224,7 +1225,6 @@ export class BuiltInAgent extends AbstractAgent {
12241225
}
12251226

12261227
case "finish": {
1227-
closeReasoningIfOpen();
12281228
// Emit run finished event
12291229
const finishedEvent: RunFinishedEvent = {
12301230
type: EventType.RUN_FINISHED,
@@ -1240,7 +1240,6 @@ export class BuiltInAgent extends AbstractAgent {
12401240
}
12411241

12421242
case "error": {
1243-
closeReasoningIfOpen();
12441243
if (abortController.signal.aborted) {
12451244
break;
12461245
}

0 commit comments

Comments
 (0)