Skip to content

Commit 843777f

Browse files
committed
feat(chat): add custom agent mailbox helpers
1 parent 7d9f1a3 commit 843777f

15 files changed

Lines changed: 913 additions & 142 deletions

File tree

.changeset/tidy-mailboxes-wait.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@trigger.dev/core": patch
3+
"@trigger.dev/sdk": patch
4+
---
5+
6+
Custom agent loops can now inspect pending chat input without consuming it and consume one mailbox record at a time with `chat.messages.hasPending()` and `chat.messages.next()`. Mailbox records include stable identifiers for tracing and redelivery.

docs/ai-chat/custom-agents.mdx

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,14 +213,54 @@ For full control, skip `createSession` and compose the primitives directly:
213213

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

224+
### `chat.messages` mailbox
225+
226+
`chat.messages` exposes the incoming message mailbox for hand-rolled loops:
227+
228+
| Method | Behavior |
229+
| --- | --- |
230+
| `peek()` | Return the buffer head when it is a message, without consuming it; otherwise return `undefined` |
231+
| `hasPending()` | Resolve `true` when any message is buffered; does not consume it |
232+
| `next({ timeoutInSeconds? })` | Consume exactly one message record in channel order, or resolve `undefined` when the optional timeout elapses |
233+
| `on(handler)` | Consume messages as they arrive and invoke the handler |
234+
| `waitWithIdleTimeout(options)` | Wait warm, then suspend the run until the next message arrives |
235+
236+
`hasPending()` checks the local, already-delivered buffer. It does not query the
237+
remote Session channel or start a subscription. Use `waitWithIdleTimeout()` when
238+
the loop needs to idle until future input arrives.
239+
240+
`next()` returns a readonly record envelope:
241+
242+
```ts
243+
const record = await chat.messages.next({ timeoutInSeconds: 5 });
244+
if (record) {
245+
console.log(record.id, record.seqNum);
246+
currentPayload = record.payload;
247+
}
248+
```
249+
250+
- `id` is the append's stable idempotency key.
251+
- `seqNum` is the monotonic sequence on this Session's `.in` channel.
252+
- `payload` is the existing `ChatTaskWirePayload` delivered by the other mailbox methods.
253+
254+
Both identifiers remain the same if the record is delivered again after a
255+
reconnect. Each `next()` call commits only the record it returns, so a loop that
256+
owns its own turn sequencing never advances past input it has not taken. By
257+
contrast, `on()` commits a record as soon as it dispatches the handler; avoid
258+
mixing `on()` and `next()` when a single loop owns mailbox consumption.
259+
260+
The Session `.in` channel also carries control records such as handovers. If one
261+
comes before a message, `next()` leaves it for its own consumer and waits until
262+
that record has been handled.
263+
224264
A complete loop:
225265

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

docs/ai-chat/reference.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -506,7 +506,7 @@ All methods available on the `chat` object from `@trigger.dev/sdk/ai`.
506506
| `chat.pipeAndCapture(source, options?)` | Pipe and capture the response; returns `{ message, status, error }` |
507507
| `chat.writeTurnComplete(options?)` | Signal turn complete; returns `{ lastEventId, sessionInEventId }` resume cursors |
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()` |

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/sessionStreams/index.ts

Lines changed: 48 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";
@@ -43,10 +49,51 @@ export class SessionStreamsAPI implements SessionStreamManager {
4349
return this.#getManager().once(sessionId, io, options);
4450
}
4551

52+
public onceRecord(
53+
sessionId: string,
54+
io: SessionChannelIO,
55+
options?: InputStreamOnceOptions
56+
): InputStreamOncePromise<SessionStreamRecord> {
57+
const manager = this.#getManager();
58+
if (!manager.onceRecord) {
59+
throw new Error("The configured Session stream manager does not support record metadata");
60+
}
61+
return manager.onceRecord(sessionId, io, options);
62+
}
63+
64+
public onceRecordWhere(
65+
sessionId: string,
66+
io: SessionChannelIO,
67+
predicate: SessionStreamRecordPredicate,
68+
options?: InputStreamOnceOptions
69+
): InputStreamOncePromise<SessionStreamRecord> {
70+
const manager = this.#getManager();
71+
if (!manager.onceRecordWhere) {
72+
throw new Error("The configured Session stream manager does not support selective records");
73+
}
74+
return manager.onceRecordWhere(sessionId, io, predicate, options);
75+
}
76+
4677
public peek(sessionId: string, io: SessionChannelIO): unknown | undefined {
4778
return this.#getManager().peek(sessionId, io);
4879
}
4980

81+
public peekRecord(sessionId: string, io: SessionChannelIO): SessionStreamRecord | undefined {
82+
return this.#getManager().peekRecord?.(sessionId, io);
83+
}
84+
85+
public peekRecordWhere(
86+
sessionId: string,
87+
io: SessionChannelIO,
88+
predicate: SessionStreamRecordPredicate
89+
): SessionStreamRecord | undefined {
90+
const manager = this.#getManager();
91+
if (!manager.peekRecordWhere) {
92+
throw new Error("The configured Session stream manager does not support selective records");
93+
}
94+
return manager.peekRecordWhere(sessionId, io, predicate);
95+
}
96+
5097
public lastSeqNum(sessionId: string, io: SessionChannelIO): number | undefined {
5198
return this.#getManager().lastSeqNum(sessionId, io);
5299
}

packages/core/src/v3/sessionStreams/manager.test.ts

Lines changed: 123 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import type { SSEStreamPart } from "../apiClient/runStream.js";
1111
// an empty stream synchronously triggers a tight reconnect loop, so the
1212
// mock parks indefinitely instead.
1313
function singleShotApiClient(
14-
records: Array<{ id: string; chunk: unknown; timestamp: number }>
14+
records: Array<{ id: string; recordId?: string; chunk: unknown; timestamp: number }>
1515
): ApiClient {
1616
let delivered = false;
1717
return {
@@ -160,3 +160,125 @@ describe("StandardSessionStreamManager — minTimestamp filter", () => {
160160
manager.disconnect();
161161
});
162162
});
163+
164+
describe("StandardSessionStreamManager — record metadata", () => {
165+
const sessionId = "session-records";
166+
const io = "in" as const;
167+
const records = [
168+
{
169+
id: "41",
170+
recordId: "part-stable-1",
171+
chunk: { kind: "message", payload: { id: "u1" } },
172+
timestamp: 1000,
173+
},
174+
{
175+
id: "42",
176+
recordId: "part-stable-2",
177+
chunk: { kind: "message", payload: { id: "u2" } },
178+
timestamp: 2000,
179+
},
180+
];
181+
182+
it("consumes one record at a time with stable id and sequence metadata", async () => {
183+
const manager = new StandardSessionStreamManager(
184+
singleShotApiClient(records),
185+
"http://localhost"
186+
);
187+
188+
const first = await manager.onceRecord(sessionId, io);
189+
expect(first).toEqual({
190+
ok: true,
191+
output: {
192+
id: "part-stable-1",
193+
seqNum: 41,
194+
data: { kind: "message", payload: { id: "u1" } },
195+
},
196+
});
197+
expect(manager.peekRecord(sessionId, io)).toEqual({
198+
id: "part-stable-2",
199+
seqNum: 42,
200+
data: { kind: "message", payload: { id: "u2" } },
201+
});
202+
expect(manager.lastDispatchedSeqNum(sessionId, io)).toBe(41);
203+
204+
const second = await manager.onceRecord(sessionId, io);
205+
expect(second.ok && second.output.id).toBe("part-stable-2");
206+
expect(manager.lastDispatchedSeqNum(sessionId, io)).toBe(42);
207+
208+
manager.disconnectStream(sessionId, io);
209+
manager.disconnect();
210+
});
211+
212+
it("returns the same envelope when a record is redelivered", async () => {
213+
const firstDelivery = new StandardSessionStreamManager(
214+
singleShotApiClient([records[0]!]),
215+
"http://localhost"
216+
);
217+
const redelivery = new StandardSessionStreamManager(
218+
singleShotApiClient([records[0]!]),
219+
"http://localhost"
220+
);
221+
222+
const first = await firstDelivery.onceRecord(sessionId, io);
223+
const replayed = await redelivery.onceRecord(sessionId, io);
224+
225+
expect(first).toEqual(replayed);
226+
227+
firstDelivery.disconnectStream(sessionId, io);
228+
firstDelivery.disconnect();
229+
redelivery.disconnectStream(sessionId, io);
230+
redelivery.disconnect();
231+
});
232+
233+
it("does not consume a matching record past an earlier unmatched record", async () => {
234+
const manager = new StandardSessionStreamManager(
235+
singleShotApiClient([
236+
{
237+
id: "50",
238+
recordId: "handover-1",
239+
chunk: { kind: "handover" },
240+
timestamp: 1000,
241+
},
242+
{
243+
id: "51",
244+
recordId: "message-1",
245+
chunk: { kind: "message", payload: { id: "u1" } },
246+
timestamp: 2000,
247+
},
248+
]),
249+
"http://localhost"
250+
);
251+
252+
const pendingMessage = manager.onceRecordWhere(
253+
sessionId,
254+
io,
255+
(record) => (record.data as { kind?: string }).kind === "message",
256+
{ timeoutMs: 200 }
257+
);
258+
259+
expect(manager.peekRecordWhere(sessionId, io, (record) => record.id === "message-1")).toEqual({
260+
id: "message-1",
261+
seqNum: 51,
262+
data: { kind: "message", payload: { id: "u1" } },
263+
});
264+
expect(manager.lastDispatchedSeqNum(sessionId, io)).toBeUndefined();
265+
266+
const handover = await manager.onceRecord(sessionId, io);
267+
expect(handover).toEqual({
268+
ok: true,
269+
output: { id: "handover-1", seqNum: 50, data: { kind: "handover" } },
270+
});
271+
await expect(pendingMessage).resolves.toEqual({
272+
ok: true,
273+
output: {
274+
id: "message-1",
275+
seqNum: 51,
276+
data: { kind: "message", payload: { id: "u1" } },
277+
},
278+
});
279+
expect(manager.lastDispatchedSeqNum(sessionId, io)).toBe(51);
280+
281+
manager.disconnectStream(sessionId, io);
282+
manager.disconnect();
283+
});
284+
});

0 commit comments

Comments
 (0)