Skip to content

Commit fe09c74

Browse files
ericallamclaude
andcommitted
refactor(chat): drop the streamText handed to onAction
An action no longer calls the model itself; one that returns chat.turn() is followed by run(), which already receives the bound streamText and the agent's tools. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AxuSksX18bj1yhnLpkcQ6a
1 parent dda8210 commit fe09c74

3 files changed

Lines changed: 28 additions & 110 deletions

File tree

‎.changeset/managed-streamtext-in-run.md‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,6 @@ Spreading `chat.toStreamTextOptions()` still works and is equivalent. The differ
1313

1414
`system` can be set at the call site, on `chat.agent({ system })`, or through `chat.prompt.set()`, but only in one of them: setting it in two places throws, because no single shape merges two system values across every supported AI SDK version, and dropping one silently is the failure this seam exists to prevent. Injected instructions append to whichever one is in play.
1515

16-
`chat.agent()` also takes `registry`, `cacheControl` and `systemProviderOptions` now, so a managed prompt's model and its cache breakpoint no longer have to be passed at the call site.
17-
18-
`onAction` receives the same `streamText`, so a response produced from an action, a regenerate especially, answers with the agent's own system prompt and tools. Built with the `streamText` imported from `ai` it answered with none, and the reply still looked fine, which is what made the difference easy to miss.
16+
`chat.agent()` also takes `registry`, `cacheControl` and `systemProviderOptions` now, so a managed prompt's model and its cache breakpoint no longer have to be passed at the call site. `chat.toStreamTextOptions()` applies them as well, so spreading it into the `streamText` imported from `ai` stays equivalent to the one `run()` receives.
1917

2018
`chat.headStart` and `chat.startHeadStart` hand their `run` the same thing, carrying the four options the handover protocol depends on. There it matters more: re-setting `messages`, `stopWhen` or `abortSignal` after a spread breaks the handover rather than degrading a feature, and nothing caught it. On the managed one those keys are a type error.

‎packages/trigger-sdk/src/v3/ai.ts‎

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5714,19 +5714,6 @@ export type ActionEvent<
57145714
uiMessages: TUIM[];
57155715
/** The accumulated model messages (after hydration, if set). */
57165716
messages: ModelMessage[];
5717-
/** The agent's resolved tools, the same set `run()` receives. */
5718-
tools: ToolSet;
5719-
/**
5720-
* `streamText` with the agent's managed options already applied, the same one
5721-
* `run()` receives: the prompt from `chat.prompt.set()` or
5722-
* `chat.agent({ system })`, the skill tools, the registry-resolved model and
5723-
* telemetry.
5724-
*
5725-
* Use it for a response produced from an action. A regenerate built with the
5726-
* `streamText` imported from `ai` answers without the agent's own system
5727-
* prompt, which is a behaviour difference nobody expects from a regenerate.
5728-
*/
5729-
streamText: ChatStreamText;
57305717
};
57315718

57325719
/**
@@ -8188,13 +8175,6 @@ function chatAgent<
81888175
clientData,
81898176
uiMessages: accumulatedUIMessages,
81908177
messages: accumulatedMessages,
8191-
tools: locals.get(chatResolvedToolsKey) ?? {},
8192-
streamText: createBoundStreamText(
8193-
promptRegistry,
8194-
agentSystem,
8195-
agentCacheControl,
8196-
agentSystemProviderOptions
8197-
),
81988178
});
81998179
},
82008180
{

‎packages/trigger-sdk/test/bound-streamtext.test.ts‎

Lines changed: 27 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { mockChatAgent } from "../src/v3/test/index.js";
55
import { describe, expect, it } from "vitest";
66
import { chat, __buildManagedStreamTextOptionsForTests as buildManaged } from "../src/v3/ai.js";
77
import { chat as chatServer } from "../src/v3/chat-server.js";
8-
import { convertToModelMessages, simulateReadableStream, stepCountIs, tool } from "ai";
8+
import { simulateReadableStream, stepCountIs, tool } from "ai";
99
import { MockLanguageModelV3 } from "ai/test";
1010
import type { LanguageModelV3StreamPart } from "@ai-sdk/provider";
1111
import { z } from "zod";
@@ -441,117 +441,57 @@ describe("what the types reject", () => {
441441
});
442442
});
443443

444-
describe("the streamText handed to onAction", () => {
445-
it("carries the agent's system prompt into a regenerated answer", async () => {
444+
describe("a turn started from an action", () => {
445+
it("answers with the agent's configuration, like any turn", async () => {
446446
/**
447-
* A regenerate is the agent answering again, so it has to answer with the
448-
* agent's own instructions. Built with the `streamText` imported from `ai`
449-
* it answers with none, and nothing reports that: the reply looks fine and
450-
* is simply produced by a differently-configured model than every other
451-
* turn.
447+
* A regenerate is the agent answering again. As a turn on the edited
448+
* history it answers with the agent's own instructions and tools, because
449+
* the same run() runs; nothing is reconstructed in the handler.
452450
*/
453-
const turnModel = new MockLanguageModelV3({
454-
doStream: async () => ({ stream: textStream("first answer") }),
455-
});
456-
const actionModel = new MockLanguageModelV3({
457-
doStream: async () => ({ stream: textStream("regenerated answer") }),
458-
});
459-
460-
const agent = chat.agent({
461-
id: "bound-streamtext-onaction",
462-
system: "AGENT-SYSTEM-FOR-ACTIONS",
463-
actionSchema: z.discriminatedUnion("type", [z.object({ type: z.literal("regenerate") })]),
464-
onAction: async ({ action, streamText }) => {
465-
if (action.type !== "regenerate") return;
466-
chat.history.slice(0, -1);
467-
return streamText({
468-
model: actionModel,
469-
messages: await convertToModelMessages(chat.history.all()),
470-
});
451+
const prompts: string[] = [];
452+
const toolNames: string[][] = [];
453+
let calls = 0;
454+
const model = new MockLanguageModelV3({
455+
doStream: async ({ prompt, tools }) => {
456+
prompts.push(JSON.stringify(prompt));
457+
toolNames.push((tools ?? []).map((t) => t.name));
458+
return { stream: textStream(calls++ === 0 ? "first answer" : "regenerated answer") };
471459
},
472-
run: async ({ messages, signal, streamText }) =>
473-
streamText({ model: turnModel, messages, abortSignal: signal }),
474-
});
475-
476-
const harness = mockChatAgent(agent, { chatId: "bound-streamtext-onaction" });
477-
478-
try {
479-
await harness.sendMessage({ id: "u1", role: "user", parts: [{ type: "text", text: "ask" }] });
480-
await new Promise((r) => setTimeout(r, 40));
481-
482-
// The turn carries it, which is the baseline.
483-
expect(JSON.stringify(turnModel.doStreamCalls.at(-1)!.prompt)).toContain(
484-
"AGENT-SYSTEM-FOR-ACTIONS"
485-
);
486-
487-
await harness.sendAction({ type: "regenerate" });
488-
await new Promise((r) => setTimeout(r, 60));
489-
490-
// And so does the action's own stream.
491-
expect(actionModel.doStreamCalls).toHaveLength(1);
492-
const actionPrompt = JSON.stringify(actionModel.doStreamCalls[0]!.prompt);
493-
expect(actionPrompt).toContain("AGENT-SYSTEM-FOR-ACTIONS");
494-
495-
/**
496-
* And the answer being replaced is gone. `onAction`'s `messages` argument
497-
* is captured before the handler runs, so passing it through after a
498-
* `chat.history` mutation regenerates against the state just removed. The
499-
* documented pattern rebuilds from `chat.history.all()`.
500-
*/
501-
expect(actionPrompt).not.toContain("first answer");
502-
} finally {
503-
await harness.close();
504-
}
505-
});
506-
507-
it("carries the agent's tools into a regenerated answer", async () => {
508-
/**
509-
* A regenerate that can call nothing is not the same agent answering
510-
* again. `onAction` has no `tools` in scope the way `run()` does, so
511-
* omitting `tools` has to fall back to the agent's set rather than to
512-
* none.
513-
*/
514-
const turnModel = new MockLanguageModelV3({
515-
doStream: async () => ({ stream: textStream("first answer") }),
516460
});
517-
const actionModel = new MockLanguageModelV3({
518-
doStream: async () => ({ stream: textStream("regenerated answer") }),
519-
});
520-
521461
const agentOnlyTool = tool({
522462
description: "declared only on chat.agent({ tools })",
523463
inputSchema: z.object({ a: z.string() }),
524464
execute: async () => "a",
525465
});
526466

527467
const agent = chat.agent({
528-
id: "bound-streamtext-onaction-tools",
468+
id: "bound-streamtext-action-turn",
469+
system: "AGENT-SYSTEM-FOR-ACTIONS",
529470
tools: { agentOnlyTool },
530471
actionSchema: z.discriminatedUnion("type", [z.object({ type: z.literal("regenerate") })]),
531-
onAction: async ({ action, streamText }) => {
472+
onAction: async ({ action }) => {
532473
if (action.type !== "regenerate") return;
533474
chat.history.slice(0, -1);
534-
return streamText({
535-
model: actionModel,
536-
messages: await convertToModelMessages(chat.history.all()),
537-
});
475+
return chat.turn();
538476
},
539477
run: async ({ messages, tools, signal, streamText }) =>
540-
streamText({ model: turnModel, messages, tools, abortSignal: signal }),
478+
streamText({ model, messages, tools, abortSignal: signal }),
541479
});
542480

543-
const harness = mockChatAgent(agent, { chatId: "bound-streamtext-onaction-tools" });
481+
const harness = mockChatAgent(agent, { chatId: "bound-streamtext-action-turn" });
544482

545483
try {
546484
await harness.sendMessage({ id: "u1", role: "user", parts: [{ type: "text", text: "ask" }] });
547485
await new Promise((r) => setTimeout(r, 40));
548-
549486
await harness.sendAction({ type: "regenerate" });
550-
await new Promise((r) => setTimeout(r, 60));
487+
await new Promise((r) => setTimeout(r, 80));
551488

552-
expect(actionModel.doStreamCalls).toHaveLength(1);
553-
const names = (actionModel.doStreamCalls[0]!.tools ?? []).map((t) => t.name);
554-
expect(names).toEqual(["agentOnlyTool"]);
489+
expect(prompts).toHaveLength(2);
490+
const regen = prompts[1]!;
491+
expect(regen).toContain("AGENT-SYSTEM-FOR-ACTIONS");
492+
expect(toolNames[1]).toEqual(["agentOnlyTool"]);
493+
// The answer being replaced is gone from what the model sees.
494+
expect(regen).not.toContain("first answer");
555495
} finally {
556496
await harness.close();
557497
}

0 commit comments

Comments
 (0)