Skip to content

Commit 023432d

Browse files
ericallamclaude
andcommitted
feat(chat): send actions through useChat so their turns render
TriggerChatTransport recognises body.action on a useChat request and sends it as an action, so useChat owns the response and a turn that follows the action renders like a message turn. useChatActions is a thin wrapper over sendMessage(undefined, { body: { action } }). transport.sendAction is unchanged for callers outside useChat and takes an optional abortSignal. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AxuSksX18bj1yhnLpkcQ6a
1 parent 472eaf4 commit 023432d

5 files changed

Lines changed: 124 additions & 16 deletions

File tree

‎.changeset/use-chat-actions.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@trigger.dev/sdk": minor
3+
---
4+
5+
Actions are sent through `useChat` so a turn that follows one renders like any turn. `TriggerChatTransport` recognises `body.action` on a `useChat` request and sends it as an action, so `sendMessage(undefined, { body: { action } })` or `regenerate({ body: { action } })` sends the action and `useChat` owns the response: it streams into the message list, `status` and `error` behave as for a message, and `stop` works. `useChatActions({ sendMessage })` in `@trigger.dev/sdk/chat/react` is a two-line convenience over that.
6+
7+
```tsx
8+
const { sendMessage } = useChat({ id: chatId, transport });
9+
const { sendAction } = useChatActions({ sendMessage });
10+
sendAction({ type: "regenerate" });
11+
```
12+
13+
Previously the frontend docs said `useChat` consumed the stream `transport.sendAction` returns; it never did, so an action's answer was never rendered by an app following them. `transport.sendAction` is unchanged for callers outside `useChat` and still returns a stream the caller must read.

‎docs/ai-chat/frontend.mdx‎

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -442,45 +442,48 @@ function Chat({ chatId, transport }) {
442442

443443
## Sending actions
444444

445-
Send custom actions (undo, rollback, edit) to the agent via `transport.sendAction()`. Actions wake the agent and fire only `hydrateMessages` (if configured) and `onAction` — they're not turns, so `onTurnStart` / `prepareMessages` / `onBeforeTurnComplete` / `onTurnComplete` and `run()` do not fire.
446-
447-
For optimistic UI, mirror the action's effect on the `useChat` state via `setMessages` while the request is in flight:
445+
Send custom actions (undo, rollback, edit, regenerate) as `useChat` requests, with the action in the request `body`. The transport recognises `body.action` and sends it as an action rather than a message, and because `useChat` made the request it owns the response: an action that returns `chat.turn()` on the server streams its answer into the message list like any turn, with `status`, `error` and `stop` behaving as for a message. An action that returns nothing completes with no message added.
448446

449447
```tsx
448+
import { useChat } from "@ai-sdk/react";
449+
import { useChatActions, useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";
450+
450451
function ChatControls({ chatId }: { chatId: string }) {
451452
const transport = useTriggerChatTransport({
452453
task: "my-chat",
453454
accessToken: ({ chatId }) => mintChatAccessToken(chatId),
454-
startSession: ({ chatId, clientData }) =>
455-
startChatSession({ chatId, clientData }),
455+
startSession: ({ chatId, clientData }) => startChatSession({ chatId, clientData }),
456456
});
457-
458-
const { setMessages } = useChat({ transport });
457+
const { sendMessage, regenerate, setMessages } = useChat({ id: chatId, transport });
458+
const { sendAction } = useChatActions({ sendMessage });
459459

460460
return (
461461
<div>
462462
<button
463463
onClick={() => {
464-
void transport.sendAction(chatId, { type: "undo" });
464+
// Mirror the server-side edit optimistically; the server does not
465+
// push history changes back.
465466
setMessages((prev) => prev.slice(0, -2));
467+
void sendAction({ type: "undo" });
466468
}}
467469
>
468470
Undo last exchange
469471
</button>
470-
<button
471-
onClick={() => transport.sendAction(chatId, { type: "rollback", targetMessageId: "msg-5" })}
472-
>
473-
Rollback to message
472+
{/* regenerate() removes the trailing answer locally; the server does the same before its turn */}
473+
<button onClick={() => regenerate({ body: { action: { type: "regenerate" } } })}>
474+
Regenerate
474475
</button>
475476
</div>
476477
);
477478
}
478479
```
479480

480-
The action payload is validated against the agent's `actionSchema` on the backend — invalid actions are rejected. See [Actions](/ai-chat/actions) for the backend setup.
481+
`useChatActions` is a two-line convenience over `sendMessage(undefined, { body: { action } })`. Any `useChat` request can carry an action the same way, `regenerate({ body })` included.
482+
483+
The action payload is validated against the agent's `actionSchema` on the backend; invalid actions are rejected. See [Actions](/ai-chat/actions) for the backend setup.
481484

482485
<Note>
483-
`sendAction` returns a `ReadableStream<UIMessageChunk>`. For side-effect-only actions (where `onAction` returns `void`), the stream completes immediately with `trigger:turn-complete`. For actions where `onAction` returns a `StreamTextResult`, the stream carries the assistant chunks the same way `sendMessages` does — `useChat` consumes them automatically.
486+
`transport.sendAction()` still exists for callers outside `useChat` (server to server, or a custom client). It returns the response as a raw `ReadableStream<UIMessageChunk>` that the caller must read; `useChat` does not consume it.
484487
</Note>
485488

486489
For server-to-server usage, `AgentChat` has the same method:

‎packages/trigger-sdk/src/v3/chat-react.ts‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -471,3 +471,46 @@ export function usePendingMessages<TUIMessage extends UIMessage = UIMessage>(
471471
getInjectedMessages,
472472
};
473473
}
474+
475+
/**
476+
* Send actions through `useChat`, so an action that returns `chat.turn()` on
477+
* the server renders like any turn.
478+
*
479+
* `transport.sendAction` returns the response as a stream that `useChat` never
480+
* reads. This hook sends the action as a `useChat` request instead (the
481+
* transport recognises `body.action` and sends it as an action), so `useChat`
482+
* owns the response: it streams into the message list, `status` and `error`
483+
* behave as for a message, and `stop` works. An action that returns nothing on
484+
* the server completes with no message added.
485+
*
486+
* History changes the action makes on the server (an undo removing messages,
487+
* for example) are not mirrored automatically; apply those with `setMessages`
488+
* optimistically, as before.
489+
*
490+
* @example
491+
* ```tsx
492+
* const transport = useTriggerChatTransport({ task: "my-chat", accessToken });
493+
* const { messages, sendMessage, setMessages } = useChat({ id: chatId, transport });
494+
* const { sendAction } = useChatActions({ sendMessage });
495+
*
496+
* <button onClick={() => sendAction({ type: "regenerate" })}>Regenerate</button>
497+
* ```
498+
*/
499+
export function useChatActions(options: {
500+
/** `useChat`'s `sendMessage`. */
501+
sendMessage: (
502+
message: undefined,
503+
options?: { body?: object; headers?: Record<string, string> | Headers }
504+
) => Promise<void> | void;
505+
}): {
506+
/** Send an action; resolves when `useChat` has finished the request. */
507+
sendAction: (action: unknown) => Promise<void>;
508+
} {
509+
const { sendMessage } = options;
510+
const sendMessageRef = useRef(sendMessage);
511+
sendMessageRef.current = sendMessage;
512+
const sendAction = useCallback(async (action: unknown) => {
513+
await sendMessageRef.current(undefined, { body: { action } });
514+
}, []);
515+
return { sendAction };
516+
}

‎packages/trigger-sdk/src/v3/chat.test.ts‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -982,6 +982,42 @@ describe("TriggerChatTransport", () => {
982982
expect(actionBody.payload.action).toEqual({ type: "undo" });
983983
});
984984

985+
it("sends a useChat request carrying body.action as an action", async () => {
986+
// `useChatActions` and `regenerate({ body })` reach the transport through
987+
// `sendMessages`; the action has to go out as an action, not a message,
988+
// and the response comes back on the request useChat made.
989+
let actionBody: any;
990+
global.fetch = vi.fn().mockImplementation(async (url: string | URL, init?: RequestInit) => {
991+
const urlStr = typeof url === "string" ? url : url.toString();
992+
if (isSessionStreamAppendUrl(urlStr)) {
993+
actionBody = JSON.parse(init!.body as string);
994+
return defaultAppendResponse();
995+
}
996+
if (isSessionOutSubscribeUrl(urlStr)) return defaultSseResponse();
997+
throw new Error(`Unexpected URL: ${urlStr}`);
998+
});
999+
1000+
const transport = new TriggerChatTransport({
1001+
task: "my-chat-task",
1002+
accessToken: () => "pat",
1003+
sessions: { "chat-act-body": { publicAccessToken: "p" } },
1004+
});
1005+
1006+
const stream = await transport.sendMessages({
1007+
trigger: "submit-message",
1008+
chatId: "chat-act-body",
1009+
messageId: undefined,
1010+
messages: [{ id: "u1", role: "user", parts: [{ type: "text", text: "hi" }] }],
1011+
abortSignal: undefined,
1012+
body: { action: { type: "regenerate" } },
1013+
});
1014+
await drainChunks(stream);
1015+
1016+
expect(actionBody.payload.trigger).toBe("action");
1017+
expect(actionBody.payload.action).toEqual({ type: "regenerate" });
1018+
expect(actionBody.payload.message).toBeUndefined();
1019+
});
1020+
9851021
it("marks the session streaming and notifies before subscribing", async () => {
9861022
global.fetch = vi.fn().mockImplementation(async (url: string | URL) => {
9871023
const urlStr = typeof url === "string" ? url : url.toString();

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

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -802,6 +802,15 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
802802
? { ...(this.defaultMetadata ?? {}), ...((metadata as Record<string, unknown>) ?? {}) }
803803
: undefined;
804804

805+
// An action sent through `useChat`. `useChatActions` (or any caller) puts
806+
// it in `body.action`; sending it here rather than through
807+
// `transport.sendAction` means `useChat` owns the response stream, so an
808+
// action that becomes a turn renders the way a message turn does.
809+
const actionInBody = (body as { action?: unknown } | undefined)?.action;
810+
if (actionInBody !== undefined) {
811+
return this.sendAction(chatId, actionInBody, { abortSignal });
812+
}
813+
805814
// First-turn handover routing — when `headStart` is set AND no
806815
// session state exists yet for this chatId, POST the wire payload
807816
// to the customer's `chat.handover` route handler. The handler
@@ -1257,7 +1266,11 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
12571266
* `StreamTextResult`); for `void`-returning side-effect-only actions
12581267
* the stream completes immediately with `trigger:turn-complete`.
12591268
*/
1260-
sendAction = async (chatId: string, action: unknown): Promise<ReadableStream<UIMessageChunk>> => {
1269+
sendAction = async (
1270+
chatId: string,
1271+
action: unknown,
1272+
options?: { abortSignal?: AbortSignal }
1273+
): Promise<ReadableStream<UIMessageChunk>> => {
12611274
if (this.coordinator) {
12621275
if (this.coordinator.isReadOnly(chatId)) {
12631276
throw new Error("This chat is active in another tab");
@@ -1306,7 +1319,7 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
13061319
this.notifySessionChange(chatId, state);
13071320

13081321
// Owning action: aborting this send stops the turn the user drives.
1309-
return this.subscribeToSessionStream(state, undefined, chatId, {
1322+
return this.subscribeToSessionStream(state, options?.abortSignal, chatId, {
13101323
sinceInSeq: inSeq,
13111324
sendStopOnAbort: true,
13121325
});

0 commit comments

Comments
 (0)