Skip to content

Commit d332a2a

Browse files
ericallamclaude
andcommitted
feat(chat): let an action become a turn with chat.turn()
An action is a state edit. To answer after the edit, onAction returns chat.turn() and a turn runs on the edited history with everything a turn has: system prompt, tools, steering, compaction, instructions, hooks, numbering and persistence. Returning a StreamTextResult, string or UIMessage is no longer supported and fails with a pointer to chat.turn(). The response path this replaces was a turn without a turn's guarantees: each had to be re-added by hand, and the browser never rendered the result reliably. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AxuSksX18bj1yhnLpkcQ6a
1 parent 023432d commit d332a2a

8 files changed

Lines changed: 410 additions & 613 deletions
Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,19 @@
11
---
2-
"@trigger.dev/sdk": patch
2+
"@trigger.dev/sdk": minor
33
---
44

5-
A response returned from `onAction` is now part of the conversation, whether it is a `StreamTextResult`, a `string`, or an assistant `UIMessage`. A `string` or `UIMessage` return was documented but did nothing: it reached neither the browser nor the conversation. Returning a `StreamTextResult` from an action sent it to the browser and nowhere else, so a regenerate showed the user a new answer that the model had no memory of, and the next turn carried on from the answer it had replaced.
5+
Actions can now become turns. `onAction` edits history with `chat.history`; to answer after the edit, return `chat.turn()` and a turn runs on the edited history with everything a turn has: the agent's system prompt and tools, steering, compaction, injected instructions, `onTurnStart` and `onTurnComplete`, and persistence. A regenerate is `chat.history.slice(0, -1); return chat.turn();`.
66

7-
A stream that fails part-way through is also no longer committed as though it finished. Whatever streamed is still kept, but the failure is reported instead of the truncated text being stored, and built on, as a complete answer. An action that used to end quietly on a mid-stream failure now surfaces an error to the frontend. It is still an action, not a turn: `onTurnComplete` does not fire for it, the turn count is unchanged, and an instruction injected for the next turn still reaches that turn.
7+
```ts
8+
onAction: async ({ action }) => {
9+
if (action.type === "regenerate") {
10+
chat.history.slice(0, -1);
11+
return chat.turn();
12+
}
13+
if (action.type === "undo") chat.history.slice(0, -2); // edit only
14+
},
15+
```
16+
17+
Returning a `StreamTextResult`, `string` or `UIMessage` from `onAction` is no longer supported and now fails with an error pointing to `chat.turn()`. A response produced that way skipped every turn guarantee, and its delivery to the browser was unreliable: the frontend never read the stream `transport.sendAction` returned, so a regenerate that appeared to work on the server did not render. The `onAction` event no longer carries `streamText` or `tools`, since the handler no longer calls the model.
18+
19+
History edits made by an action are still persisted as before: platform-managed snapshots are written after the edit, and apps with their own store mirror the edit themselves.

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

Lines changed: 63 additions & 159 deletions
Original file line numberDiff line numberDiff line change
@@ -5009,62 +5009,42 @@ type UIMessageStreamable = {
50095009
toUIMessageStream: (...args: any[]) => AsyncIterable<unknown> | ReadableStream<unknown>;
50105010
};
50115011

5012+
const actionTurnBrand = Symbol.for("trigger.dev/chat/actionTurn");
5013+
50125014
/**
5013-
* A plain `onAction` reply (`string` or assistant `UIMessage`) as a stream, so
5014-
* it takes the same path as a `StreamTextResult`: piped to the browser,
5015-
* captured, committed to the conversation, snapshotted. Text and `data-*`
5016-
* parts are emitted; anything else in a supplied message is dropped, since a
5017-
* tool part with no execution behind it cannot be replayed as chunks.
5018-
*/
5019-
function plainReplyAsStream(value: unknown): UIMessageStreamable | undefined {
5020-
let message: UIMessage | undefined;
5021-
if (typeof value === "string") {
5022-
message = {
5023-
id: generateMessageId(),
5024-
role: "assistant",
5025-
parts: [{ type: "text", text: value }],
5026-
} as UIMessage;
5027-
} else if (
5028-
typeof value === "object" &&
5029-
value !== null &&
5030-
(value as UIMessage).role === "assistant" &&
5031-
Array.isArray((value as UIMessage).parts)
5032-
) {
5033-
const m = value as UIMessage;
5034-
message = { ...m, id: m.id || generateMessageId() };
5035-
}
5036-
if (!message) return undefined;
5015+
* The marker `onAction` returns to have a turn run on the history it just
5016+
* edited. Produce it with {@link chat.turn}.
5017+
*/
5018+
export type ActionTurn = { readonly [actionTurnBrand]: true };
50375019

5038-
const chunks: Record<string, unknown>[] = [{ type: "start", messageId: message.id }];
5039-
let n = 0;
5040-
for (const part of message.parts as {
5041-
type: string;
5042-
text?: string;
5043-
data?: unknown;
5044-
id?: string;
5045-
}[]) {
5046-
if (part.type === "text") {
5047-
const id = `t${n++}`;
5048-
chunks.push(
5049-
{ type: "text-start", id },
5050-
{ type: "text-delta", id, delta: part.text ?? "" },
5051-
{ type: "text-end", id }
5052-
);
5053-
} else if (part.type.startsWith("data-")) {
5054-
chunks.push({ type: part.type, id: part.id, data: part.data });
5055-
}
5056-
}
5057-
chunks.push({ type: "finish" });
5020+
/**
5021+
* Turn the current action into a turn.
5022+
*
5023+
* Return it from `onAction` after editing history. The action's own work is
5024+
* finished first (the edit is applied and snapshotted), then a turn runs on the
5025+
* result exactly as a message turn does: `onTurnStart`, `run()` with the edited
5026+
* history, `onBeforeTurnComplete`, `onTurnComplete`, and the turn counter
5027+
* advances. That gives the answer everything a turn has, the system prompt,
5028+
* tools, steering, compaction, injected instructions and persistence, with no
5029+
* action-specific handling.
5030+
*
5031+
* @example
5032+
* ```ts
5033+
* onAction: async ({ action }) => {
5034+
* if (action.type === "regenerate") {
5035+
* chat.history.slice(0, -1);
5036+
* return chat.turn();
5037+
* }
5038+
* if (action.type === "undo") chat.history.slice(0, -2); // no turn
5039+
* },
5040+
* ```
5041+
*/
5042+
function chatTurn(): ActionTurn {
5043+
return { [actionTurnBrand]: true } as ActionTurn;
5044+
}
50585045

5059-
return {
5060-
toUIMessageStream: () =>
5061-
new ReadableStream({
5062-
start(controller) {
5063-
for (const c of chunks) controller.enqueue(c);
5064-
controller.close();
5065-
},
5066-
}),
5067-
} as unknown as UIMessageStreamable;
5046+
function isActionTurn(value: unknown): value is ActionTurn {
5047+
return typeof value === "object" && value !== null && (value as any)[actionTurnBrand] === true;
50685048
}
50695049

50705050
/**
@@ -5858,17 +5838,18 @@ export type ChatAgentOptions<
58585838
* `onBeforeTurnComplete` / `onTurnComplete`, no `run()`. Use
58595839
* `chat.history.*` inside `onAction` to mutate state.
58605840
*
5861-
* To produce a model response from an action, return a
5862-
* `StreamTextResult` (auto-piped), `string`, or `UIMessage`. Returning
5863-
* `void` or nothing is the side-effect-only default.
5841+
* To answer after the edit, return `chat.turn()`: the edit is applied and
5842+
* snapshotted, then a turn runs on the result with every turn guarantee. An
5843+
* action that returns nothing is a state edit only. Returning anything else
5844+
* is an error; a response can no longer be returned directly.
58645845
*/
58655846
onAction?: (
58665847
event: ActionEvent<
58675848
[TActionSchema] extends [TaskSchema] ? inferSchemaOut<TActionSchema> : unknown,
58685849
inferSchemaOut<TClientDataSchema>,
58695850
TUIMessage
58705851
>
5871-
) => Promise<unknown> | unknown;
5852+
) => Promise<void | ActionTurn> | void | ActionTurn;
58725853

58735854
/**
58745855
* The tools available to this agent.
@@ -7880,7 +7861,9 @@ function chatAgent<
78807861
// an action, return a `StreamTextResult` (auto-piped),
78817862
// string, or UIMessage from `onAction`. Turn counter
78827863
// does not advance.
7883-
let actionStreamResult: unknown = undefined;
7864+
let actionResult: unknown = undefined;
7865+
/** Set when `onAction` returned `chat.turn()`: the turn block below runs. */
7866+
let actionTurn = false;
78847867
/**
78857868
* Whether this action changed the conversation, by rolling history
78867869
* back or by streaming a response. Drives the single snapshot write
@@ -7927,7 +7910,7 @@ function chatAgent<
79277910
// Fire onAction — handler may mutate state via
79287911
// `chat.history.*` and / or return a model response.
79297912
if (onAction) {
7930-
actionStreamResult = await tracer.startActiveSpan(
7913+
actionResult = await tracer.startActiveSpan(
79317914
"onAction()",
79327915
async () => {
79337916
return await onAction({
@@ -8250,110 +8233,29 @@ function chatAgent<
82508233
// The turn counter is decremented so the next iteration
82518234
// sees the same `turn` value — actions don't count.
82528235
if (isAction) {
8253-
msgSub?.off();
8254-
8255-
// A documented plain reply takes the streamed reply's path.
8256-
actionStreamResult = plainReplyAsStream(actionStreamResult) ?? actionStreamResult;
8257-
8258-
if (
8259-
(locals.get(chatPipeCountKey) ?? 0) === 0 &&
8260-
isUIMessageStreamable(actionStreamResult)
8261-
) {
8262-
try {
8263-
/**
8264-
* Captured, not just piped. The stream reaching the browser was
8265-
* never the problem — the problem was that it stopped there, so
8266-
* the user read an answer the accumulator had no record of and
8267-
* the next turn contradicted the screen. Worst on regenerate,
8268-
* which removes the old answer and used to leave nothing in its
8269-
* place.
8270-
*
8271-
* Persistence beyond the snapshot is still the app's job: an
8272-
* action fires no `onTurnComplete`, so an app owning its own
8273-
* store has to write the row itself — `chat.pipeAndCapture`
8274-
* hands back the same message for that.
8275-
*/
8276-
const captured = await pipeChatAndCapture(
8277-
actionStreamResult as UIMessageStreamable,
8278-
{ signal: combinedSignal, spanName: "stream response" }
8279-
);
8280-
8281-
if (runSignal.aborted) return "exit";
8282-
8283-
/**
8284-
* A stopped action still commits what streamed, cleaned:
8285-
* incomplete tool and text parts left mid-flight are what
8286-
* strand the UI on a spinner forever once persisted.
8287-
*/
8288-
const actionResponse =
8289-
captured.status === "complete" || !captured.message
8290-
? captured.message
8291-
: cleanupAbortedParts(captured.message);
8292-
8293-
if (actionResponse) {
8294-
const existingIdx = actionResponse.id
8295-
? accumulatedUIMessages.findIndex((m) => m.id === actionResponse.id)
8296-
: -1;
8297-
if (existingIdx !== -1) {
8298-
accumulatedUIMessages[existingIdx] = actionResponse as TUIMessage;
8299-
// Replacing an existing message has no in-place model
8300-
// form to swap, so this path still reconverts.
8301-
accumulatedMessages = await toModelMessages(accumulatedUIMessages);
8302-
} else {
8303-
accumulatedUIMessages.push(actionResponse as TUIMessage);
8304-
// Appended, not reconverted: a reconversion from the UI
8305-
// lane would undo a model-only compaction summary.
8306-
accumulatedMessages.push(
8307-
...(await toModelMessages([stripProviderMetadata(actionResponse)]))
8308-
);
8309-
}
8310-
locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
8311-
actionChangedHistory = true;
8312-
}
8313-
8314-
/**
8315-
* Reported after the partial is committed, not instead of it.
8316-
* `pipeChatAndCapture` returns a stream failure rather than
8317-
* throwing, so without this a mid-stream failure writes a
8318-
* normal turn-complete and the truncated answer is persisted
8319-
* as if it were finished — the next turn then builds on it.
8320-
*/
8321-
if (captured.status === "error") throw captured.error;
8322-
} catch (error) {
8323-
if (
8324-
error instanceof Error &&
8325-
error.name === "AbortError" &&
8326-
runSignal.aborted
8327-
) {
8328-
return "exit";
8329-
}
8330-
// Reported here rather than rethrown: the shared catch
8331-
// below is the turn-error path, and it would fire
8332-
// onTurnComplete, keep the turn number and consume the
8333-
// one-shot instruction lane, none of which an action does.
8334-
try {
8335-
await withChatWriter(async (writer) => {
8336-
const errorText =
8337-
error instanceof Error ? error.message : "An unexpected error occurred";
8338-
writer.write({ type: "error", errorText } as any);
8339-
});
8340-
} catch {
8341-
// best effort
8342-
}
8236+
if (isActionTurn(actionResult)) {
8237+
// The edit is in the accumulators; the turn block below
8238+
// runs on it and does its own persistence, hooks and
8239+
// completion, so nothing more happens here.
8240+
actionTurn = true;
8241+
} else if (actionResult !== undefined) {
8242+
throw new Error(
8243+
"chat.agent: onAction returned a value. An action is a state edit; to answer " +
8244+
"after the edit, return chat.turn() and a turn runs on the edited history. " +
8245+
"Returning a StreamTextResult, string or UIMessage is no longer supported."
8246+
);
8247+
} else {
8248+
msgSub?.off();
8249+
if (actionChangedHistory) {
8250+
await writeSnapshotOutsideTurn("action");
83438251
}
8252+
await writeTurnCompleteChunk(currentWirePayload.chatId);
8253+
// Don't consume a turn iteration — actions aren't turns.
8254+
turn--;
83448255
}
8345-
8346-
if (actionChangedHistory) {
8347-
await writeSnapshotOutsideTurn("action");
8348-
}
8349-
8350-
await writeTurnCompleteChunk(currentWirePayload.chatId);
8351-
8352-
// Don't consume a turn iteration — actions aren't turns.
8353-
turn--;
83548256
}
83558257

8356-
if (!isAction) {
8258+
if (!isAction || actionTurn) {
83578259
// Mint a scoped public access token once per turn, reused for
83588260
// onChatStart, onTurnStart, onTurnComplete, and the turn-complete chunk.
83598261
const currentRunId = ctx.run.id;
@@ -12420,6 +12322,8 @@ export const chat = {
1242012322
createStartSessionAction: createChatStartSessionAction,
1242112323
/** Pipe a stream to the chat transport. See {@link pipeChat}. */
1242212324
pipe: pipeChat,
12325+
/** Return from `onAction` to run a turn on the edited history. See {@link chatTurn}. */
12326+
turn: chatTurn,
1242312327
/** Create a per-run typed local. See {@link chatLocal}. */
1242412328
local: chatLocal,
1242512329
/** Create a public access token for a chat task. See {@link createChatAccessToken}. */

0 commit comments

Comments
 (0)