Skip to content

Commit 743c0ab

Browse files
committed
Fix token usage display and run cancellation
1 parent 7e51f80 commit 743c0ab

6 files changed

Lines changed: 176 additions & 6 deletions

File tree

apps/web/src/app/data-tasks/__tests__/live-run-state.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1259,6 +1259,29 @@ describe("live run state reducer", () => {
12591259
expect(session.tokens.outputTokens).toBe(340);
12601260
});
12611261

1262+
it("does not double count duplicate token_usage custom events", () => {
1263+
let run = createInitialLiveRun();
1264+
const event = {
1265+
type: "CUSTOM" as const,
1266+
name: "token_usage",
1267+
value: {
1268+
step_number: 1,
1269+
model: "qwen-plus",
1270+
input_tokens: 1200,
1271+
output_tokens: 340,
1272+
},
1273+
};
1274+
1275+
run = reduceLiveRunEvent(run, { type: "RUN_STARTED" });
1276+
run = reduceLiveRunEvent(run, event);
1277+
run = reduceLiveRunEvent(run, event);
1278+
1279+
const runUsage = deriveRunUsage(run);
1280+
expect(runUsage.tokens.inputTokens).toBe(1200);
1281+
expect(runUsage.tokens.outputTokens).toBe(340);
1282+
expect(run.tokenUsageEvents).toHaveLength(1);
1283+
});
1284+
12621285
it("tracks token_usage model, cost, and step-level usage", () => {
12631286
let run = createInitialLiveRun();
12641287
run = reduceLiveRunEvent(run, { type: "RUN_STARTED" });

apps/web/src/app/data-tasks/__tests__/mastra-stream-hooks.test.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ import {
44
createMastraStreamNormalizerHooks,
55
tokenUsageEventFromChunk,
66
} from "../../../../../../packages/agent-runtime/src/stream/mastra-stream-hooks";
7+
import {
8+
normalizeMastraFullStream,
9+
wrapAgentForAgUi,
10+
} from "../../../../../../packages/agent-runtime/src/stream/mastra-stream-normalizer";
711

812
describe("tokenUsageEventFromChunk", () => {
913
it("extracts usage, tool_call_id, and tool_name from step output", () => {
@@ -78,3 +82,95 @@ describe("createMastraStreamNormalizerHooks token emission", () => {
7882
});
7983
});
8084
});
85+
86+
describe("normalizeMastraFullStream", () => {
87+
it("drops Mastra internal chunks before AG-UI processing", async () => {
88+
const chunks = normalizeMastraFullStream(streamFrom([
89+
{ type: "abort", payload: { reason: "cancelled" } },
90+
{ type: "text-start", payload: { id: "msg-1" } },
91+
{ type: "finish", payload: {} },
92+
]));
93+
94+
await expect(collectStream(chunks)).resolves.toEqual([{ type: "finish", payload: {} }]);
95+
});
96+
});
97+
98+
describe("wrapAgentForAgUi abort signal passthrough", () => {
99+
it("passes the run abort signal into stream options", async () => {
100+
const runAbortController = new AbortController();
101+
let receivedSignal: AbortSignal | undefined;
102+
const agent = wrapAgentForAgUi(
103+
{
104+
async stream(_messages: unknown, options?: { abortSignal?: AbortSignal; marker?: string }) {
105+
receivedSignal = options?.abortSignal;
106+
return { fullStream: emptyStream() };
107+
},
108+
},
109+
{},
110+
{ abortSignal: runAbortController.signal },
111+
);
112+
113+
await agent.stream([], { marker: "kept" });
114+
115+
expect(receivedSignal).toBe(runAbortController.signal);
116+
});
117+
118+
it("merges an existing stream abort signal with the run abort signal", async () => {
119+
const upstreamAbortController = new AbortController();
120+
const runAbortController = new AbortController();
121+
let receivedSignal: AbortSignal | undefined;
122+
const agent = wrapAgentForAgUi(
123+
{
124+
async stream(_messages: unknown, options?: { abortSignal?: AbortSignal }) {
125+
receivedSignal = options?.abortSignal;
126+
return { fullStream: emptyStream() };
127+
},
128+
},
129+
{},
130+
{ abortSignal: runAbortController.signal },
131+
);
132+
133+
await agent.stream([], { abortSignal: upstreamAbortController.signal });
134+
runAbortController.abort(new Error("run cancelled"));
135+
136+
expect(receivedSignal).not.toBe(upstreamAbortController.signal);
137+
expect(receivedSignal?.aborted).toBe(true);
138+
});
139+
140+
it("passes the run abort signal into resumeStream options", async () => {
141+
const runAbortController = new AbortController();
142+
let receivedSignal: AbortSignal | undefined;
143+
const agent = wrapAgentForAgUi(
144+
{
145+
async resumeStream(_resumeData: unknown, options?: { abortSignal?: AbortSignal }) {
146+
receivedSignal = options?.abortSignal;
147+
return { fullStream: emptyStream() };
148+
},
149+
},
150+
{},
151+
{ abortSignal: runAbortController.signal },
152+
);
153+
154+
await agent.resumeStream({ answer: "yes" }, {});
155+
156+
expect(receivedSignal).toBe(runAbortController.signal);
157+
});
158+
});
159+
160+
async function* emptyStream() {
161+
yield { type: "finish", payload: {} };
162+
}
163+
164+
async function* streamFrom(chunks: Array<{ payload?: unknown; type: string }>) {
165+
for (const chunk of chunks) {
166+
yield chunk;
167+
}
168+
}
169+
170+
async function collectStream<T>(stream: AsyncIterable<T>): Promise<T[]> {
171+
const chunks: T[] = [];
172+
for await (const chunk of stream) {
173+
chunks.push(chunk);
174+
}
175+
return chunks;
176+
}

apps/web/src/app/data-tasks/live-run-state.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -919,6 +919,10 @@ function reduceCustomEvent(state: LiveRun, event: AgUiLikeEvent): LiveRun {
919919
: {}),
920920
...(stringValue(value?.model) ? { model: stringValue(value?.model) } : {}),
921921
};
922+
const deltaKey = tokenUsageRecordKey(delta);
923+
if (state.tokenUsageEvents.some((record) => tokenUsageRecordKey(record) === deltaKey)) {
924+
return state;
925+
}
922926
return {
923927
...state,
924928
tokenUsage: mergeTokenUsageStats(state.tokenUsage ?? emptyTokenUsageStats(), delta),

apps/web/src/app/data-tasks/use-data-foundry-run.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ export function LiveRunProvider({ children }: { children: ReactNode }) {
153153
[isRestoringConversation],
154154
);
155155

156-
useEffect(() => {
156+
useLayoutEffect(() => {
157157
const previous = prevRunStatusRef.current;
158158
const current = liveRun.runStatus;
159159
if (previous === "running" && current === "completed") {

packages/agent-runtime/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ export {
112112
export {
113113
normalizeMastraFullStream,
114114
wrapAgentForAgUi,
115+
type MastraAgentForAgUiOptions,
115116
type MastraStreamChunk,
116117
type MastraStreamNormalizerHooks
117118
} from "./stream/mastra-stream-normalizer.js";
@@ -441,6 +442,7 @@ export const createDataFoundry = async (
441442
const agentForAgUi = wrapAgentForAgUi(
442443
agent,
443444
createMastraStreamNormalizerHooks(input.emitter),
445+
{ ...(input.abortSignal ? { abortSignal: input.abortSignal } : {}) },
444446
);
445447
const mastra = input.taskStateRuntime
446448
? new Mastra({

packages/agent-runtime/src/stream/mastra-stream-normalizer.ts

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,12 @@ export type MastraStreamNormalizerHooks = {
1313
onQuarantine?: (chunk: MastraStreamChunk) => void;
1414
};
1515

16-
const PAYLOADLESS_CHUNK_TYPES = new Set([
16+
export type MastraAgentForAgUiOptions = {
17+
abortSignal?: AbortSignal | undefined;
18+
};
19+
20+
const INTERNAL_CHUNK_TYPES = new Set([
21+
"abort",
1722
"text-start",
1823
"text-end",
1924
"tool-call-input-streaming-start",
@@ -48,7 +53,7 @@ export async function* normalizeMastraFullStream(
4853
continue;
4954
}
5055

51-
if (type && PAYLOADLESS_CHUNK_TYPES.has(type) && chunk.payload === undefined) {
56+
if (type && INTERNAL_CHUNK_TYPES.has(type)) {
5257
continue;
5358
}
5459

@@ -85,15 +90,16 @@ export async function* normalizeMastraFullStream(
8590
/** Wrap a local Mastra Agent so only fullStream is normalized for AG-UI consumption. */
8691
export function wrapAgentForAgUi<TAgent extends object>(
8792
agent: TAgent,
88-
hooks: MastraStreamNormalizerHooks = {}
93+
hooks: MastraStreamNormalizerHooks = {},
94+
options: MastraAgentForAgUiOptions = {}
8995
): TAgent {
9096
return new Proxy(agent, {
9197
get(target, prop, receiver) {
9298
const value = Reflect.get(target, prop, receiver);
9399

94-
if (prop === "stream" && typeof value === "function") {
100+
if ((prop === "stream" || prop === "resumeStream") && typeof value === "function") {
95101
return async (...args: unknown[]) => {
96-
const response = await value.apply(target, args);
102+
const response = await value.apply(target, withAbortSignal(args, options.abortSignal));
97103
if (!response || typeof response !== "object") {
98104
return response;
99105
}
@@ -122,6 +128,45 @@ export function wrapAgentForAgUi<TAgent extends object>(
122128
}) as TAgent;
123129
}
124130

131+
const withAbortSignal = (args: unknown[], abortSignal?: AbortSignal | undefined): unknown[] => {
132+
if (!abortSignal) {
133+
return args;
134+
}
135+
const nextArgs = [...args];
136+
const streamOptions = isRecord(nextArgs[1]) ? nextArgs[1] : {};
137+
nextArgs[1] = {
138+
...streamOptions,
139+
abortSignal: mergeAbortSignals(streamOptions.abortSignal, abortSignal)
140+
};
141+
return nextArgs;
142+
};
143+
144+
const mergeAbortSignals = (
145+
first: unknown,
146+
second: AbortSignal
147+
): AbortSignal => {
148+
if (!(first instanceof AbortSignal)) {
149+
return second;
150+
}
151+
if (first === second) {
152+
return second;
153+
}
154+
if (typeof AbortSignal.any === "function") {
155+
return AbortSignal.any([first, second]);
156+
}
157+
if (first.aborted) {
158+
return AbortSignal.abort(first.reason);
159+
}
160+
if (second.aborted) {
161+
return AbortSignal.abort(second.reason);
162+
}
163+
const controller = new AbortController();
164+
const abort = (signal: AbortSignal): void => controller.abort(signal.reason);
165+
first.addEventListener("abort", () => abort(first), { once: true });
166+
second.addEventListener("abort", () => abort(second), { once: true });
167+
return controller.signal;
168+
};
169+
125170
const stringifyStreamError = (error: unknown): string => {
126171
if (error instanceof Error) {
127172
return error.message;

0 commit comments

Comments
 (0)