From 8e78f75d3d5ac7ae466924467c3f74321736c894 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Tue, 28 Apr 2026 12:04:34 -0700 Subject: [PATCH] [ai] Expose totalUsage and finishReason on DurableAgent stream result The DurableAgentStreamResult interface omitted totalUsage and finishReason, which were already computed and passed to the onFinish callback. This made them unreachable for callers that await stream() directly, and diverged from the AI SDK's GenerateTextResult/StreamTextResult shape. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../durable-agent-stream-result-totalusage.md | 5 +++ .../ai/src/agent/durable-agent-compat.test.ts | 24 ++++++++++++++ packages/ai/src/agent/durable-agent.ts | 33 +++++++++++++++---- 3 files changed, 56 insertions(+), 6 deletions(-) create mode 100644 .changeset/durable-agent-stream-result-totalusage.md diff --git a/.changeset/durable-agent-stream-result-totalusage.md b/.changeset/durable-agent-stream-result-totalusage.md new file mode 100644 index 0000000000..e72d4d9014 --- /dev/null +++ b/.changeset/durable-agent-stream-result-totalusage.md @@ -0,0 +1,5 @@ +--- +"@workflow/ai": minor +--- + +Expose `totalUsage` and `finishReason` on the `DurableAgent.stream()` result, mirroring the AI SDK's `GenerateTextResult`/`StreamTextResult` and the existing `onFinish` event payload. diff --git a/packages/ai/src/agent/durable-agent-compat.test.ts b/packages/ai/src/agent/durable-agent-compat.test.ts index 9c9b95c2a0..a5207a7ff8 100644 --- a/packages/ai/src/agent/durable-agent-compat.test.ts +++ b/packages/ai/src/agent/durable-agent-compat.test.ts @@ -1348,6 +1348,30 @@ describe('DurableAgent (ToolLoopAgent compat)', () => { } `); }); + + it('should expose finishReason and totalUsage on the stream result', async () => { + const agent = new DurableAgent({ + model: asModelFactory(mockModel), + }); + + const { writable } = createMockWritable(); + const result = await agent.stream({ + messages: [{ role: 'user' as const, content: 'test' }], + writable, + }); + + expect({ + finishReason: result.finishReason, + inputTokens: result.totalUsage.inputTokens, + outputTokens: result.totalUsage.outputTokens, + }).toMatchInlineSnapshot(` + { + "finishReason": "stop", + "inputTokens": 3, + "outputTokens": 10, + } + `); + }); }); }); diff --git a/packages/ai/src/agent/durable-agent.ts b/packages/ai/src/agent/durable-agent.ts index a84d7214dc..0727a7d8fc 100644 --- a/packages/ai/src/agent/durable-agent.ts +++ b/packages/ai/src/agent/durable-agent.ts @@ -735,6 +735,16 @@ export interface DurableAgentStreamResult< */ toolResults: ToolResult[]; + /** + * The finish reason from the last step. + */ + finishReason: FinishReason; + + /** + * The total token usage across all steps. + */ + totalUsage: LanguageModelUsage; + /** * The generated structured output. It uses the `experimental_output` specification. * Only available when `experimental_output` is specified. @@ -980,6 +990,8 @@ export class DurableAgent { steps, toolCalls: [], toolResults: [], + finishReason: 'other', + totalUsage: aggregateUsage(steps), experimental_output: undefined as OUTPUT, uiMessages: undefined, }; @@ -1168,15 +1180,17 @@ export class DurableAgent { // resolved tool results), so callers can resume the conversation. // Cast matches the existing pattern used at the end of stream(). const messages = iterMessages as unknown as ModelMessage[]; + const lastStep = steps[steps.length - 1]; + const totalUsage = aggregateUsage(steps); + const finishReason = lastStep?.finishReason ?? 'other'; if (mergedOnFinish && !wasAborted) { - const lastStep = steps[steps.length - 1]; await mergedOnFinish({ steps, messages, text: lastStep?.text ?? '', - finishReason: lastStep?.finishReason ?? 'other', - totalUsage: aggregateUsage(steps), + finishReason, + totalUsage, experimental_context: experimentalContext, experimental_output: undefined as OUTPUT, }); @@ -1191,6 +1205,8 @@ export class DurableAgent { steps, toolCalls: allToolCalls, toolResults: allToolResults, + finishReason, + totalUsage, experimental_output: undefined as OUTPUT, uiMessages, }; @@ -1328,15 +1344,18 @@ export class DurableAgent { } } + const lastStep = steps[steps.length - 1]; + const totalUsage = aggregateUsage(steps); + const finishReason = lastStep?.finishReason ?? 'other'; + // Call onFinish callback if provided (always call, even on errors, but not on abort) if (mergedOnFinish && !wasAborted) { - const lastStep = steps[steps.length - 1]; await mergedOnFinish({ steps, messages: messages as ModelMessage[], text: lastStep?.text ?? '', - finishReason: lastStep?.finishReason ?? 'other', - totalUsage: aggregateUsage(steps), + finishReason, + totalUsage, experimental_context: experimentalContext, experimental_output: experimentalOutput, }); @@ -1358,6 +1377,8 @@ export class DurableAgent { steps, toolCalls: lastStepToolCalls, toolResults: lastStepToolResults, + finishReason, + totalUsage, experimental_output: experimentalOutput, uiMessages, };