Skip to content

Commit c115f44

Browse files
gtremperericallam
andauthored
feat(chat): custom agent mailbox helpers and session.in delivery fixes (#4644)
## Summary Adds `chat.messages.hasPending()` and `chat.messages.next()` so a custom agent loop can inspect pending chat input without consuming it and take one record at a time, and fixes four ways a chat could mishandle input across a restart: a message silently lost, a recovered answer cut off by a stop the user had already pressed, a retried send answered twice, and a record the agent had no consumer for blocking every message queued behind it. ```ts if (await chat.messages.hasPending()) { const record = await chat.messages.next({ timeoutInSeconds: 0 }); if (record) handle(record.payload); } ``` ## Why the fixes came together `session.in` carries records for consumers whose delivery needs differ. A user message must be delivered eventually, so it can wait arbitrarily long for a turn to take it. A stop only means anything to the turn that is live when it lands. Progress along the channel was tracked as one sequence number, and one number cannot say "control applied through 7, message 3 still owed" at the same time. Each of the bugs above is that mismatch surfacing somewhere different. So instead of a rule per symptom, records are now classified once and handed to one route, and each route declares two things: whether it holds a record when no consumer is ready, and whether a record it never handled has to survive into the next boot. The resume cursor, the replay window and the discard-the-unowned behaviour are then derived from route state rather than maintained beside it, and `hasPending()` answers from the message queue instead of the head of a buffer shared with every other kind. The wire is unchanged. Both cursors on the turn boundary keep their meanings, so existing chats resume as before and there is no webapp change. ## Behaviour worth calling out `chat.writeTurnComplete()`'s `sessionInEventId` is the cursor that is safe to resume from, not the sequence of the record the turn answered. It is held back behind any message still waiting to be handled, so a value below the record you just handled is expected rather than a sign of a lost turn. The stop fix also covers chats whose most recent turn was completed by an older SDK, by resolving the replay window from the channel when the boundary does not carry one. The trade there is deliberate: a stop that landed in the moments before boot and was never applied is dropped along with the replayed ones, because a stop the user can press again beats a stale one killing an answer they are waiting for. ## Verification Thirteen reproductions against a local stack, each driving real runs rather than mocks, covering the documented `next()`/`hasPending()` loop, suspend and resume, a crash between consuming a message and writing turn-complete, a retried send whose idempotency claim is lost, and a continuation boot that must not replay answered messages. Where applicable each was also run against `main`, so the fixes are differences rather than assertions. Five further legs on a deployed environment, which the earlier revisions of this branch did not cover at all: a message appended while the run is genuinely checkpointed, a message appended while the run is dead, the stop-after-crash case on the real crash path, and both version-skew directions (a newer worker resuming an older worker's turn boundary, and an older worker resuming a newer one's). Two of those restart fixes also have a browser-driven red and green pair on a deployed environment, staged identically on both sides and differing only in the SDK. For the lost-message fix, the unanswered message is replayed and answered in full here, and is never replayed at all on the released SDK. For the stop fix, both sides replay the message and diverge on the stop itself: it is declined here and the answer completes, while the released SDK re-applies it and the recovered answer dies before it streams. The routing decision itself is a pure state machine, so it also has a property test over every interleaving of the record kinds crossed with each crash point, checked by mutation to confirm it fails when the cursor arithmetic or the replay window is broken. ## Known and not addressed here The read of the woken record is unbounded, so a wake with nothing to read makes `wait()` outlive its own waitpoint. Tested and not a deadlock, since the read defers to the next record, but bounding it is a separate change with its own test. Separately, and not caused by this branch: a run that crashes while a message is still queued is not replaced until the next inbound append, so that message waits rather than being recovered on its own. Worth its own issue. Also not caused by this branch, but worth knowing when reading the release note: a chat page that stayed open across the crash keeps showing the partial answer it already received, so the recovered answer only appears after a reload. The answer itself is persisted correctly. The gap is on the client, which does not apply a re-delivered turn over a partial it already holds. --------- Co-authored-by: Eric Allam <eric@trigger.dev> Co-authored-by: Eric Allam <eallam@icloud.com>
1 parent c7f78e4 commit c115f44

26 files changed

Lines changed: 3182 additions & 602 deletions

.changeset/tidy-mailboxes-wait.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
"@trigger.dev/core": patch
3+
"@trigger.dev/sdk": patch
4+
---
5+
6+
Fixes a case where a chat could silently lose a message. If a message arrived while the agent was between turns and a stop arrived after it, the cursor the next boot resumed from could point past that message, so it was never answered and no error was raised. This affected `chat.agent`, not just custom agents.
7+
8+
Fixes a recovered answer being cut off. After a crash the agent replays the message it had not answered yet, but it was replaying the stop that arrived after that message too, so the turn answering it was aborted the moment it began. A stop is now only applied to the turn that was live when it arrived. That holds however the stop got there: sent after the last completed turn, or sent to a chat whose most recent turn was completed by an older version of the SDK.
9+
10+
One limitation to know about: the recovered answer is persisted correctly, but a chat page that stayed open across the crash keeps showing the partial answer it had already received. Reload the page to see the full recovered answer.
11+
12+
Also fixes a retried send being answered twice. When a send was retried and its idempotency claim was lost, the agent could consume the same message a second time.
13+
14+
Custom agent loops can now inspect pending chat input without consuming it, and consume one record at a time, with `chat.messages.hasPending()` and `chat.messages.next()`. Records carry stable identifiers so a redelivery is recognisable.
15+
16+
```ts
17+
if (await chat.messages.hasPending()) {
18+
const record = await chat.messages.next({ timeoutInSeconds: 0 });
19+
if (record) handle(record.payload);
20+
}
21+
```
22+
23+
`hasPending()` answers for messages alone, so a message sitting behind a stop, or behind a record this version of the SDK does not recognise, still reports as pending and is still delivered. Anything the agent has no consumer for is discarded rather than left where it would make every message queued behind it undeliverable. `chat.messages.next()` returning `undefined` means no message became consumable before the timeout.
24+
25+
`chat.writeTurnComplete()`'s `sessionInEventId` is the cursor that is safe to resume from, not the sequence of the record the turn answered. It is held back behind any message still waiting to be handled, so a value below the record you just handled is expected.

docs/ai-chat/custom-agents.mdx

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,12 @@ for await (const turn of session) {
179179

180180
The frontend stops a turn with [`transport.stopGeneration(chatId)`](/ai-chat/frontend#stop-generation), which writes a stop signal to the session's input stream. It aborts the current turn's generation but keeps the run alive, so the next message continues on the same session.
181181

182+
A stop only applies to the turn that was live when it arrived. If the run crashes
183+
and a later run recovers a message that had not been answered yet, a stop that
184+
was already applied before the crash is not applied again, so the turn answering
185+
the recovered message runs to completion. A stop sent after the recovery is live
186+
and aborts that turn as normal.
187+
182188
`turn.signal` is a combined stop-and-cancel `AbortSignal`, fresh each turn. Pass it to `streamText` so the stop reaches the model, then let `turn.complete()` finish the turn:
183189

184190
```ts trigger/my-chat.ts
@@ -213,14 +219,65 @@ For full control, skip `createSession` and compose the primitives directly:
213219

214220
| Primitive | Description |
215221
| ------------------------------- | -------------------------------------------------------------------------------------------- |
216-
| `chat.messages` | Input stream for incoming messages — use `.waitWithIdleTimeout()` to wait for the next turn |
222+
| `chat.messages` | Mailbox for incoming messages — inspect buffered input, consume one record, or suspend until the next turn |
217223
| `chat.createStopSignal()` | Create a managed stop signal wired to the stop input stream |
218224
| `chat.pipeAndCapture(result)` | Pipe a stream and capture the response; returns `{ message, status, error }` |
219225
| `chat.writeTurnComplete()` | Signal turn complete; returns `{ lastEventId, sessionInEventId }` resume cursors |
220226
| `chat.MessageAccumulator` | Accumulates conversation messages across turns |
221227
| `chat.pipe(stream)` | Pipe a stream to the frontend (no response capture) |
222228
| `chat.cleanupAbortedParts(msg)` | Clean up incomplete parts from a stopped response |
223229

230+
### `chat.messages` mailbox
231+
232+
`chat.messages` exposes the incoming message mailbox for hand-rolled loops:
233+
234+
| Method | Behavior |
235+
| --- | --- |
236+
| `peek()` | Return the next queued message without consuming it, or `undefined` when none is queued |
237+
| `hasPending()` | Resolve `true` when a message is queued; does not consume it |
238+
| `next({ timeoutInSeconds? })` | Consume exactly one message record in channel order, or resolve `undefined` when the optional timeout elapses |
239+
| `on(handler)` | Consume messages as they arrive and invoke the handler |
240+
| `waitWithIdleTimeout(options)` | Wait warm, then suspend the run until the next message arrives |
241+
242+
`hasPending()` checks whether a message has already been delivered locally and is
243+
waiting for `next()` to take it. It does not query the remote Session channel or
244+
start a subscription. Use `waitWithIdleTimeout()` when the loop needs to idle
245+
until future input arrives.
246+
247+
`next({ timeoutInSeconds: 0 })` is also a local, non-blocking read. Call
248+
`next()` without a timeout, or with a positive timeout, to subscribe for future
249+
input.
250+
251+
`next()` returns a readonly record envelope:
252+
253+
```ts
254+
const record = await chat.messages.next({ timeoutInSeconds: 5 });
255+
if (record) {
256+
console.log(record.id, record.seqNum);
257+
currentPayload = record.payload;
258+
}
259+
```
260+
261+
- `id` is the append's stable idempotency key.
262+
- `seqNum` is the monotonic sequence on this Session's `.in` channel.
263+
- `payload` is the existing `ChatTaskWirePayload` delivered by the other mailbox methods.
264+
265+
Both identifiers remain the same if the record is delivered again after a
266+
reconnect. Each `next()` call commits only the record it returns, so a loop that
267+
owns its own turn sequencing never advances past input it has not taken. By
268+
contrast, `on()` commits a record as soon as it dispatches the handler; avoid
269+
mixing `on()` and `next()` when a single loop owns mailbox consumption.
270+
271+
The Session `.in` channel also carries control records such as stops and
272+
handovers. Those are routed to their own consumers and never block messages: a
273+
message that arrived behind one is still reported by `hasPending()` and still
274+
returned by `next()`, in channel order. The same holds for a record kind this
275+
version of the SDK does not recognise, which is discarded rather than left where
276+
it would make every message behind it undeliverable.
277+
278+
`next()` returns `undefined` when no message became consumable before the
279+
timeout.
280+
224281
A complete loop:
225282

226283
```ts trigger/my-chat-raw.ts

docs/ai-chat/reference.mdx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -504,9 +504,9 @@ All methods available on the `chat` object from `@trigger.dev/sdk/ai`.
504504
| `chat.createSession(payload, options)` | Create an async iterator for chat turns |
505505
| `chat.pipe(source, options?)` | Pipe a stream to the frontend (from anywhere inside a task) |
506506
| `chat.pipeAndCapture(source, options?)` | Pipe and capture the response; returns `{ message, status, error }` |
507-
| `chat.writeTurnComplete(options?)` | Signal turn complete; returns `{ lastEventId, sessionInEventId }` resume cursors |
507+
| `chat.writeTurnComplete(options?)` | Signal turn complete; returns `{ lastEventId, sessionInEventId }` resume cursors. `sessionInEventId` is a lower bound, not the sequence of the record the turn answered |
508508
| `chat.createStopSignal()` | Create a managed stop signal wired to the stop input stream |
509-
| `chat.messages` | Input stream for incoming messages — use `.waitWithIdleTimeout()` |
509+
| `chat.messages` | Incoming message mailbox; supports non-consuming `.peek()` / `.hasPending()`, single-record `.next()`, `.on()`, and suspend-aware `.waitWithIdleTimeout()` |
510510
| `chat.local<T>({ id })` | Create a per-run typed local (see [`chat.local`](/ai-chat/chat-local)) |
511511
| `chat.createStartSessionAction(taskId, options?)` | Returns a server action that creates a chat Session + triggers the first run + returns a session-scoped PAT. Idempotent on `(env, externalId)`. |
512512
| `chat.waitForHandover(options)` | Wait for a [`chat.headStart`](/ai-chat/fast-starts#handover-with-custom-agents) handover signal in a custom loop. Returns the signal or `null`. `chat.MessageAccumulator` wraps this as `consumeHandover()` / `applyHandover()` |
@@ -645,7 +645,7 @@ The `onEvent` callback receives a `ChatTransportEvent` (exported from `@trigger.
645645
| `message-send-failed` | `messageId?`, `source`, `error`, `status?`, `durationMs`, `partId?`, `bodyBytes?` | A send definitively failed after internal retries. Fires in addition to `useChat`'s `onError`. |
646646
| `stream-connected` | `resumed`, `lastEventId?`, `messageId?` | The SSE subscription to the session's output stream started delivering. `resumed: true` when reconnecting from a stored cursor (page reload) rather than following a fresh send. `lastEventId` is the cursor it connected from. |
647647
| `first-chunk` | `chunkType?`, `lastEventId?`, `messageId?`, `sinceSendMs?` | The first response chunk of a turn arrived. `sinceSendMs` is the delta from the last turn-producing send — time to first token without any bookkeeping. |
648-
| `turn-completed` | `lastEventId?`, `sessionInEventId?`, `messageId?`, `sinceSendMs?` | The agent's turn-complete control record arrived — the "finished answering" signal. `sinceSendMs` is the full turn latency; `sessionInEventId` is the agent's committed input-stream cursor. |
648+
| `turn-completed` | `lastEventId?`, `sessionInEventId?`, `messageId?`, `sinceSendMs?` | The agent's turn-complete control record arrived — the "finished answering" signal. `sinceSendMs` is the full turn latency; `sessionInEventId` is the cursor the agent can safely resume its input stream from. Treat it as a lower bound: it is held back behind any message still waiting to be handled, so it can be below the sequence of the record this turn answered. Do not use it to decide whether a turn boundary belongs to your own send. |
649649
| `stream-error` | `error`, `status?` | The output stream failed unrecoverably. |
650650

651651
`source` identifies the send path: `"submit-message"`, `"regenerate-message"`, `"steer"` (`sendPendingMessage`), `"action"` (`sendAction`), `"stop"` (`stopGeneration`), or `"head-start"`.

packages/core/src/v3/apiClient/runStream.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,7 @@ describe("SSEStreamSubscription v2 batch parsing — record kinds", () => {
492492
});
493493

494494
type ParsedPart = {
495+
recordId?: string;
495496
id: string;
496497
chunk: unknown;
497498
headers?: ReadonlyArray<readonly [string, string]>;
@@ -548,6 +549,7 @@ describe("SSEStreamSubscription v2 batch parsing — record kinds", () => {
548549
const parts = await sub.subscribe().then(drain);
549550

550551
expect(parts).toHaveLength(1);
552+
expect(parts[0]!.recordId).toBe("p1");
551553
expect(parts[0]!.id).toBe("5");
552554
expect(parts[0]!.chunk).toEqual({ type: "text-delta", delta: "hi" });
553555
expect(parts[0]!.headers).toEqual([]);

packages/core/src/v3/apiClient/runStream.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,9 @@ export interface StreamSubscriptionFactory {
170170
}
171171

172172
export type SSEStreamPart<TChunk = unknown> = {
173+
/** Stable logical record id from the S2 data envelope (`X-Part-Id` on append). */
174+
recordId?: string;
175+
/** S2 sequence number in decimal-string form. */
173176
id: string;
174177
chunk: TChunk;
175178
timestamp: number;
@@ -502,6 +505,7 @@ export class SSEStreamSubscription implements StreamSubscription {
502505
chunkController.enqueue({
503506
type: "part",
504507
part: {
508+
recordId: parsedBody?.id,
505509
id: record.seq_num.toString(),
506510
chunk: parsedBody?.data,
507511
timestamp: record.timestamp,

packages/core/src/v3/session-streams-api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@ export const sessionStreams = SessionStreamsAPI.getInstance();
77
export * from "./sessionStreams/types.js";
88
export * from "./sessionStreams/wireProtocol.js";
99
export * from "./sessionStreams/chatSnapshot.js";
10+
export * from "./sessionStreams/router.js";

packages/core/src/v3/sessionStreams/index.ts

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { getGlobal, registerGlobal } from "../utils/globals.js";
22
import { NoopSessionStreamManager } from "./noopManager.js";
3-
import type { InputStreamOncePromise, SessionChannelIO, SessionStreamManager } from "./types.js";
3+
import type {
4+
InputStreamOncePromise,
5+
SessionChannelIO,
6+
SessionStreamManager,
7+
SessionStreamRecord,
8+
SessionStreamRecordPredicate,
9+
} from "./types.js";
410
import type { InputStreamOnceOptions } from "../realtimeStreams/types.js";
511

612
const API_NAME = "session-streams";
@@ -35,6 +41,18 @@ export class SessionStreamsAPI implements SessionStreamManager {
3541
return this.#getManager().on(sessionId, io, handler);
3642
}
3743

44+
public onRecord(
45+
sessionId: string,
46+
io: SessionChannelIO,
47+
handler: (record: SessionStreamRecord) => void | boolean | Promise<void>
48+
): { off: () => void } {
49+
const manager = this.#getManager();
50+
if (!manager.onRecord) {
51+
throw new Error("The configured Session stream manager does not support record handlers");
52+
}
53+
return manager.onRecord(sessionId, io, handler);
54+
}
55+
3856
public once(
3957
sessionId: string,
4058
io: SessionChannelIO,
@@ -43,10 +61,43 @@ export class SessionStreamsAPI implements SessionStreamManager {
4361
return this.#getManager().once(sessionId, io, options);
4462
}
4563

64+
public onceRecord(
65+
sessionId: string,
66+
io: SessionChannelIO,
67+
options?: InputStreamOnceOptions
68+
): InputStreamOncePromise<SessionStreamRecord> {
69+
const manager = this.#getManager();
70+
if (!manager.onceRecord) {
71+
throw new Error("The configured Session stream manager does not support record metadata");
72+
}
73+
return manager.onceRecord(sessionId, io, options);
74+
}
75+
76+
public onceRecordWhere(
77+
sessionId: string,
78+
io: SessionChannelIO,
79+
predicate: SessionStreamRecordPredicate,
80+
options?: InputStreamOnceOptions
81+
): InputStreamOncePromise<SessionStreamRecord> {
82+
const manager = this.#getManager();
83+
if (!manager.onceRecordWhere) {
84+
throw new Error("The configured Session stream manager does not support selective records");
85+
}
86+
return manager.onceRecordWhere(sessionId, io, predicate, options);
87+
}
88+
4689
public peek(sessionId: string, io: SessionChannelIO): unknown | undefined {
4790
return this.#getManager().peek(sessionId, io);
4891
}
4992

93+
public peekRecord(sessionId: string, io: SessionChannelIO): SessionStreamRecord | undefined {
94+
const manager = this.#getManager();
95+
if (!manager.peekRecord) {
96+
throw new Error("The configured Session stream manager does not support record metadata");
97+
}
98+
return manager.peekRecord(sessionId, io);
99+
}
100+
50101
public lastSeqNum(sessionId: string, io: SessionChannelIO): number | undefined {
51102
return this.#getManager().lastSeqNum(sessionId, io);
52103
}
@@ -55,6 +106,14 @@ export class SessionStreamsAPI implements SessionStreamManager {
55106
this.#getManager().setLastSeqNum(sessionId, io, seqNum);
56107
}
57108

109+
public consumeRecord(sessionId: string, io: SessionChannelIO, seqNum: number): void {
110+
const manager = this.#getManager();
111+
if (!manager.consumeRecord) {
112+
throw new Error("The configured Session stream manager does not support exact consumption");
113+
}
114+
manager.consumeRecord(sessionId, io, seqNum);
115+
}
116+
58117
public lastDispatchedSeqNum(sessionId: string, io: SessionChannelIO): number | undefined {
59118
return this.#getManager().lastDispatchedSeqNum(sessionId, io);
60119
}
@@ -75,6 +134,10 @@ export class SessionStreamsAPI implements SessionStreamManager {
75134
return this.#getManager().shiftBuffer(sessionId, io);
76135
}
77136

137+
public reconnectStream(sessionId: string, io: SessionChannelIO): void {
138+
this.#getManager().reconnectStream?.(sessionId, io);
139+
}
140+
78141
public disconnectStream(sessionId: string, io: SessionChannelIO): void {
79142
this.#getManager().disconnectStream(sessionId, io);
80143
}

0 commit comments

Comments
 (0)