@trigger.dev/sdk 4.5.12 → 4.5.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/dist/commonjs/v3/ai.d.ts +154 -20
  2. package/dist/commonjs/v3/ai.js +1241 -387
  3. package/dist/commonjs/v3/ai.js.map +1 -1
  4. package/dist/commonjs/v3/chat.d.ts +7 -2
  5. package/dist/commonjs/v3/chat.js +22 -7
  6. package/dist/commonjs/v3/chat.js.map +1 -1
  7. package/dist/commonjs/v3/chat.test.js +13 -4
  8. package/dist/commonjs/v3/chat.test.js.map +1 -1
  9. package/dist/commonjs/v3/envvars.js.map +1 -1
  10. package/dist/commonjs/v3/sessions.d.ts +4 -10
  11. package/dist/commonjs/v3/sessions.js +73 -47
  12. package/dist/commonjs/v3/sessions.js.map +1 -1
  13. package/dist/commonjs/v3/streams.js +1 -0
  14. package/dist/commonjs/v3/streams.js.map +1 -1
  15. package/dist/commonjs/v3/test/mock-chat-agent.js +1 -0
  16. package/dist/commonjs/v3/test/mock-chat-agent.js.map +1 -1
  17. package/dist/commonjs/v3/test/test-session-handle.js +22 -23
  18. package/dist/commonjs/v3/test/test-session-handle.js.map +1 -1
  19. package/dist/commonjs/version.js +1 -1
  20. package/dist/esm/v3/ai.d.ts +154 -20
  21. package/dist/esm/v3/ai.js +1239 -387
  22. package/dist/esm/v3/ai.js.map +1 -1
  23. package/dist/esm/v3/chat.d.ts +7 -2
  24. package/dist/esm/v3/chat.js +22 -7
  25. package/dist/esm/v3/chat.js.map +1 -1
  26. package/dist/esm/v3/chat.test.js +13 -4
  27. package/dist/esm/v3/chat.test.js.map +1 -1
  28. package/dist/esm/v3/envvars.js.map +1 -1
  29. package/dist/esm/v3/sessions.d.ts +4 -10
  30. package/dist/esm/v3/sessions.js +73 -47
  31. package/dist/esm/v3/sessions.js.map +1 -1
  32. package/dist/esm/v3/streams.js +1 -0
  33. package/dist/esm/v3/streams.js.map +1 -1
  34. package/dist/esm/v3/test/mock-chat-agent.js +2 -1
  35. package/dist/esm/v3/test/mock-chat-agent.js.map +1 -1
  36. package/dist/esm/v3/test/test-session-handle.js +23 -24
  37. package/dist/esm/v3/test/test-session-handle.js.map +1 -1
  38. package/dist/esm/version.js +1 -1
  39. package/docs/ai-chat/client-protocol.mdx +8 -3
  40. package/docs/ai-chat/custom-agents.mdx +181 -46
  41. package/docs/ai-chat/patterns/recovery-boot.mdx +9 -2
  42. package/docs/ai-chat/patterns/version-upgrades.mdx +26 -6
  43. package/docs/ai-chat/pending-messages.mdx +5 -3
  44. package/docs/ai-chat/reference.mdx +26 -10
  45. package/docs/ai-chat/types.mdx +5 -1
  46. package/docs/deployment/atomic-deployment.mdx +12 -0
  47. package/docs/deployment/overview.mdx +7 -1
  48. package/docs/deployment/version-skew-protection.mdx +430 -0
  49. package/docs/github-actions.mdx +33 -5
  50. package/docs/github-integration.mdx +12 -0
  51. package/docs/realtime/auth.mdx +18 -0
  52. package/docs/realtime/react-hooks/session-stream.mdx +109 -0
  53. package/docs/realtime/react-hooks/streams.mdx +71 -3
  54. package/docs/self-hosting/env/webapp.mdx +1 -0
  55. package/docs/self-hosting/security.mdx +1 -1
  56. package/docs/tasks/streams.mdx +3 -0
  57. package/docs/vercel-integration.mdx +43 -9
  58. package/docs/versioning.mdx +2 -0
  59. package/package.json +2 -2
package/dist/esm/v3/ai.js CHANGED
@@ -1,4 +1,4 @@
1
- import { accessoryAttributes, apiClientManager, controlSubtype, generateJWT, getSchemaParseFn, headerValue, InputStreamOncePromise, isAdditionalApiKey, isSchemaZodEsque, logger, ManualWaitpointPromise, OutOfMemoryError, resourceCatalog, SemanticInternalAttributes, SESSION_IN_EVENT_ID_HEADER, sessionStreams, taskContext, TRIGGER_CONTROL_SUBTYPE, } from "@trigger.dev/core/v3";
1
+ import { accessoryAttributes, apiClientManager, controlSubtype, generateJWT, getSchemaParseFn, headerValue, InputStreamOncePromise, isAdditionalApiKey, isSchemaZodEsque, logger, ManualWaitpointPromise, OutOfMemoryError, resourceCatalog, SemanticInternalAttributes, SESSION_IN_CONSUMED_ID_HEADER, SESSION_IN_EVENT_ID_HEADER, sessionStreams, SessionChannelRouter, InputStreamTimeoutError, taskContext, TRIGGER_CONTROL_SUBTYPE, } from "@trigger.dev/core/v3";
2
2
  // Runtime VALUES go through the ESM/CJS shim so the CJS build can `require`
3
3
  // ESM-only `ai@7` (see ../imports/ai-runtime.ts).
4
4
  import { trace } from "@opentelemetry/api";
@@ -103,49 +103,6 @@ async function findLatestSessionInCursor(chatId) {
103
103
  }
104
104
  return latestCursor;
105
105
  }
106
- /** Test-only entry point for the records-based cursor scan. @internal */
107
- export async function __findLatestSessionInCursorForTests(chatId) {
108
- return findLatestSessionInCursor(chatId);
109
- }
110
- /**
111
- * Seed the `.in` resume cursor for custom-agent loops (`chat.customAgent`
112
- * raw loops and `chat.createSession`) the way `chat.agent`'s boot does.
113
- *
114
- * MUST run before anything attaches a `.in` listener (`createStopSignal`,
115
- * `chat.messages.on`, the first wait): attaching opens the SSE tail with
116
- * `Last-Event-ID` from the seeded cursor, so attach-then-seed replays
117
- * every record from seq 0 — already-answered user messages get delivered
118
- * into the new run's first wait and the loop re-answers them.
119
- *
120
- * Seeds both cursors: `setLastSeqNum` controls the SSE `Last-Event-ID`,
121
- * `setLastDispatchedSeqNum` gates waiter dispatch — seeding only the
122
- * former still re-delivers records the manager buffered before the seed.
123
- *
124
- * No-ops on fresh boots and when a cursor is already seeded (e.g. the
125
- * `chatCustomAgent` wrapper ran before a nested `createChatSession`).
126
- * @internal
127
- */
128
- async function seedSessionInResumeCursorForCustomLoop(payload) {
129
- if (sessionStreams.lastSeqNum(payload.chatId, "in") !== undefined)
130
- return;
131
- // No continuation/attempt gate: the wire may omit `continuation` on a
132
- // run that still has prior turns (chat.agent covers that case via its
133
- // snapshot). The scan doubles as the prior-state probe — a fresh
134
- // session has no turn-complete on `.out`, returns no cursor, and
135
- // seeds nothing. Cost on fresh boots is one non-blocking records read.
136
- try {
137
- const cursor = await findLatestSessionInCursor(payload.chatId);
138
- if (cursor !== undefined) {
139
- sessionStreams.setLastSeqNum(payload.chatId, "in", cursor);
140
- sessionStreams.setLastDispatchedSeqNum(payload.chatId, "in", cursor);
141
- }
142
- }
143
- catch (error) {
144
- logger.warn("chat session: session.in resume cursor lookup failed; old messages may replay", {
145
- error: error instanceof Error ? error.message : String(error),
146
- });
147
- }
148
- }
149
106
  let readChatSnapshotImpl;
150
107
  export function __setReadChatSnapshotImplForTests(impl) {
151
108
  readChatSnapshotImpl = impl;
@@ -1009,90 +966,508 @@ async function withChatWriter(fn) {
1009
966
  await flush();
1010
967
  return result;
1011
968
  }
1012
- // Input streams for bidirectional chat communication
1013
- //
1014
- // Both `messagesInput` and `stopInput` are thin facades over the current
1015
- // run's Session `.in` channel. The Session carries a single tagged stream
1016
- // (`ChatInputChunk`); these facades filter by `kind` so existing call
1017
- // sites (both internal and exposed via `chat.messages` / `chat.createStopSignal`)
1018
- // keep their original shape. Each accessor resolves the session handle
1019
- // lazily via `getChatSession()` so the module-level references stay
1020
- // compatible with the pre-migration wiring.
969
+ const CHAT_CUSTOM_AGENT_CLIENT_DATA_ERROR_TEXT = "Invalid client data";
970
+ const chatCustomAgentClientDataParserKey = locals.create("chat.customAgentClientDataParser");
971
+ const chatCustomAgentClientDataErrorHandlerKey = locals.create("chat.customAgentClientDataErrorHandler");
972
+ function shouldValidateChatCustomAgentPayload(payload) {
973
+ return (payload.trigger !== "close" && locals.get(chatCustomAgentClientDataParserKey) !== undefined);
974
+ }
975
+ function assertChatCustomAgentSyncParseResult(result) {
976
+ if (result && typeof result.then === "function") {
977
+ void Promise.resolve(result).catch(() => { });
978
+ throw new Error("chat.messages.peek() cannot validate clientData with an asynchronous schema. " +
979
+ "Use chat.messages.once(), chat.messages.wait(), or chat.messages.waitWithIdleTimeout().");
980
+ }
981
+ return result;
982
+ }
983
+ function getChatCustomAgentSyncSchemaParseFn(schema) {
984
+ const parser = schema;
985
+ if (typeof parser === "function" && typeof parser.assert === "function") {
986
+ return parser.assert.bind(parser);
987
+ }
988
+ if (typeof parser === "function") {
989
+ return (value) => assertChatCustomAgentSyncParseResult(parser(value));
990
+ }
991
+ if (typeof parser.parse === "function") {
992
+ return (value) => assertChatCustomAgentSyncParseResult(parser.parse(value));
993
+ }
994
+ if (typeof parser.validateSync === "function") {
995
+ return parser.validateSync.bind(parser);
996
+ }
997
+ if (typeof parser.create === "function") {
998
+ return parser.create.bind(parser);
999
+ }
1000
+ if (typeof parser.assert === "function") {
1001
+ return (value) => {
1002
+ parser.assert(value);
1003
+ return value;
1004
+ };
1005
+ }
1006
+ return () => {
1007
+ throw new Error("chat.messages.peek() cannot validate clientData with this schema. " +
1008
+ "Use chat.messages.once(), chat.messages.wait(), or chat.messages.waitWithIdleTimeout().");
1009
+ };
1010
+ }
1011
+ async function writeChatCustomAgentClientDataErrorToStream(payload) {
1012
+ try {
1013
+ await withChatWriter((writer) => {
1014
+ writer.write({
1015
+ type: "error",
1016
+ errorText: CHAT_CUSTOM_AGENT_CLIENT_DATA_ERROR_TEXT,
1017
+ });
1018
+ });
1019
+ await chatWriteTurnComplete();
1020
+ }
1021
+ catch (signalError) {
1022
+ logger.warn("chat.customAgent: failed to report clientData validation error", {
1023
+ chatId: payload.chatId,
1024
+ trigger: payload.trigger,
1025
+ error: signalError instanceof Error ? signalError.message : String(signalError),
1026
+ });
1027
+ }
1028
+ }
1029
+ async function reportChatCustomAgentClientDataError(payload, error, options) {
1030
+ const errorText = error instanceof Error ? error.message : "An unexpected error occurred";
1031
+ logger.warn("chat.customAgent: clientData validation failed", {
1032
+ chatId: payload.chatId,
1033
+ trigger: payload.trigger,
1034
+ error: errorText,
1035
+ });
1036
+ const errorHandler = options.callHandler === false
1037
+ ? undefined
1038
+ : locals.get(chatCustomAgentClientDataErrorHandlerKey);
1039
+ if (errorHandler) {
1040
+ try {
1041
+ await errorHandler({ error, payload });
1042
+ }
1043
+ catch (handlerError) {
1044
+ logger.warn("chat.customAgent: clientData validation error handler failed", {
1045
+ chatId: payload.chatId,
1046
+ trigger: payload.trigger,
1047
+ error: handlerError instanceof Error ? handlerError.message : String(handlerError),
1048
+ });
1049
+ }
1050
+ }
1051
+ if (!options.writeToStream) {
1052
+ return;
1053
+ }
1054
+ await writeChatCustomAgentClientDataErrorToStream(payload);
1055
+ }
1056
+ async function parseChatCustomAgentPayload(payload) {
1057
+ const parser = locals.get(chatCustomAgentClientDataParserKey);
1058
+ if (!parser || payload.trigger === "close") {
1059
+ return { ok: true, payload };
1060
+ }
1061
+ try {
1062
+ const metadata = await parser.parse(payload.metadata);
1063
+ return { ok: true, payload: { ...payload, metadata } };
1064
+ }
1065
+ catch (error) {
1066
+ return { ok: false, error };
1067
+ }
1068
+ }
1069
+ async function validateChatCustomAgentPayload(payload, options = {}) {
1070
+ const result = await parseChatCustomAgentPayload(payload);
1071
+ if (!result.ok) {
1072
+ await reportChatCustomAgentClientDataError(payload, result.error, {
1073
+ writeToStream: options.writeErrorToStream ?? true,
1074
+ });
1075
+ }
1076
+ return result;
1077
+ }
1078
+ function validateChatCustomAgentPayloadSync(payload) {
1079
+ const parser = locals.get(chatCustomAgentClientDataParserKey);
1080
+ if (!parser || payload.trigger === "close") {
1081
+ return payload;
1082
+ }
1083
+ try {
1084
+ return { ...payload, metadata: parser.parseSync(payload.metadata) };
1085
+ }
1086
+ catch (error) {
1087
+ logger.warn("chat.customAgent: clientData validation failed in chat.messages.peek()", {
1088
+ chatId: payload.chatId,
1089
+ trigger: payload.trigger,
1090
+ error: error instanceof Error ? error.message : String(error),
1091
+ });
1092
+ throw error;
1093
+ }
1094
+ }
1095
+ /**
1096
+ * Read one record from a route, suspending the run if nothing is there yet.
1097
+ *
1098
+ * The wake and the read are separate steps: the channel wakes the run, then the
1099
+ * router hands over whatever it routed. Nothing else can take the record in
1100
+ * between, which is what keeps the published cursors and the delivered record
1101
+ * in agreement.
1102
+ * @internal
1103
+ */
1104
+ async function waitOnChatRoute(route, options) {
1105
+ const router = chatInputRouter();
1106
+ const session = getChatSession();
1107
+ return tracer.startActiveSpan(options.spanName ?? `chat.${route}.wait()`, async (span) => {
1108
+ const idleMs = (options.idleTimeoutInSeconds ?? 0) * 1000;
1109
+ if (idleMs > 0) {
1110
+ const warm = await router.next(route, { timeoutMs: idleMs });
1111
+ if (warm) {
1112
+ span.setAttribute("wait.resolved", "idle");
1113
+ return { ok: true, output: warm.data, record: warm };
1114
+ }
1115
+ }
1116
+ else {
1117
+ const buffered = await router.next(route, { timeoutMs: 0 });
1118
+ if (buffered) {
1119
+ span.setAttribute("wait.resolved", "buffered");
1120
+ return { ok: true, output: buffered.data, record: buffered };
1121
+ }
1122
+ }
1123
+ if (options.skipSuspend) {
1124
+ span.setAttribute("wait.resolved", "skipped");
1125
+ return {
1126
+ ok: false,
1127
+ error: new Error("Idle timeout elapsed and skipSuspend is set"),
1128
+ };
1129
+ }
1130
+ if (options.onSuspend)
1131
+ await options.onSuspend();
1132
+ span.setAttribute("wait.resolved", "suspended");
1133
+ while (true) {
1134
+ /**
1135
+ * The floor doubles as the wake cursor: the server completes the
1136
+ * waitpoint immediately if anything sits after this sequence, so a
1137
+ * floor that has advanced past an unread record parks a waitpoint
1138
+ * nothing will complete. Recorded on the span so a run that never woke
1139
+ * can be diagnosed from its trace alone.
1140
+ */
1141
+ const wakeFrom = router.resumeFloor();
1142
+ span.setAttribute("wait.lastSeqNum", wakeFrom ?? -1);
1143
+ const wake = await session.in.awaitWake({
1144
+ timeout: options.timeout,
1145
+ lastSeqNum: wakeFrom,
1146
+ });
1147
+ if (!wake.ok) {
1148
+ span.recordException(wake.error);
1149
+ return { ok: false, error: wake.error };
1150
+ }
1151
+ const record = await router.next(route);
1152
+ if (!record)
1153
+ continue;
1154
+ if (options.onResume)
1155
+ await options.onResume();
1156
+ return { ok: true, output: record.data, record };
1157
+ }
1158
+ }, {
1159
+ attributes: {
1160
+ [SemanticInternalAttributes.STYLE_ICON]: "sessions",
1161
+ session: session.id,
1162
+ io: "in",
1163
+ route,
1164
+ ...accessoryAttributes({
1165
+ items: [{ text: `${session.id}.in:${route}`, variant: "normal" }],
1166
+ style: "codepath",
1167
+ }),
1168
+ },
1169
+ });
1170
+ }
1171
+ /**
1172
+ * Raw `.in` message delivery, with no client-data validation.
1173
+ *
1174
+ * Sits directly on the router's `messages` route, so a record handed to the
1175
+ * handler is consumed and the resume floor is free to advance past it.
1176
+ */
1177
+ function subscribeToRawChatMessages(handler) {
1178
+ return chatInputRouter().on(CHAT_ROUTE_MESSAGES, (record) => {
1179
+ const chunk = record.data;
1180
+ void Promise.resolve(handler(chunk.payload)).catch(() => { });
1181
+ });
1182
+ }
1183
+ /**
1184
+ * Message delivery with client-data validation in front of it.
1185
+ *
1186
+ * Parses are chained so payloads are validated in wire order, which is the
1187
+ * point of the feature: a later message must not be validated against state a
1188
+ * earlier one has not established yet.
1189
+ *
1190
+ * The handler is dispatched but **not** awaited by that chain. Awaiting it
1191
+ * would serialise user code, so one slow or never-resolving handler would stall
1192
+ * delivery of every later message, and only when a schema is declared. Raw
1193
+ * delivery has always been fire-and-forget, so awaiting here would also make
1194
+ * handler concurrency differ between the validated and unvalidated paths for no
1195
+ * stated reason.
1196
+ *
1197
+ * `off()` detaches from the router but does not cancel work already chained:
1198
+ * the parse chain is a live promise chain and runs to completion on its own, so
1199
+ * a frame that arrives just before a turn closes is still parsed and a failure
1200
+ * is still reported (through the `!active` branch). Nothing therefore has to
1201
+ * wait on it, which is why no `drain()` hook is exposed. The one uncovered edge
1202
+ * is a parse still in flight when the task itself returns, where teardown can
1203
+ * cut the report short; give this a bounded wait at the run-end boundary rather
1204
+ * than an unbounded one, since the chain awaits a user-supplied schema.
1205
+ */
1206
+ function subscribeToValidatedChatMessages(handler, options = {}) {
1207
+ let active = true;
1208
+ let delivery = Promise.resolve();
1209
+ const subscription = subscribeToRawChatMessages((payload) => {
1210
+ delivery = delivery
1211
+ .then(async () => {
1212
+ const result = await parseChatCustomAgentPayload(payload);
1213
+ if (!result.ok) {
1214
+ if (active) {
1215
+ // Completing the turn here could close an active response.
1216
+ await reportChatCustomAgentClientDataError(payload, result.error, {
1217
+ writeToStream: false,
1218
+ });
1219
+ }
1220
+ else if (options.onInvalidAfterOff) {
1221
+ await options.onInvalidAfterOff(payload, result.error);
1222
+ }
1223
+ else {
1224
+ // The subscription was removed while parsing. Keep the failure
1225
+ // observable without invoking a user callback after off().
1226
+ await reportChatCustomAgentClientDataError(payload, result.error, {
1227
+ writeToStream: false,
1228
+ callHandler: false,
1229
+ });
1230
+ }
1231
+ return;
1232
+ }
1233
+ if (active) {
1234
+ void Promise.resolve(handler(result.payload, () => active)).catch(() => { });
1235
+ }
1236
+ else {
1237
+ void Promise.resolve(options.onAfterOff?.(result.payload)).catch(() => { });
1238
+ }
1239
+ })
1240
+ .catch(() => { });
1241
+ });
1242
+ return {
1243
+ off() {
1244
+ active = false;
1245
+ subscription.off();
1246
+ },
1247
+ };
1248
+ }
1249
+ /**
1250
+ * Validated delivery that does **not** consume.
1251
+ *
1252
+ * The steering path must observe rather than take: a record it does not inject
1253
+ * has to stay queued so a later turn answers it. Validation still runs in wire
1254
+ * order in front of the handler, so `clientData` is parsed before steering code
1255
+ * sees it, and a frame whose parse finishes after the turn closed needs no
1256
+ * buffer of its own, because the record was never removed from the channel.
1257
+ */
1258
+ function observeValidatedChatMessages(
1259
+ /**
1260
+ * Receives the observed record's own `seqNum`. It is passed per record rather
1261
+ * than tracked outside, because the handler runs after an await: with two
1262
+ * frames in flight a shared slot already holds the newer sequence by the time
1263
+ * the older frame's parse resolves, and the steering queue would then take
1264
+ * the wrong record off the channel.
1265
+ */
1266
+ handler) {
1267
+ let active = true;
1268
+ let delivery = Promise.resolve();
1269
+ const claims = locals.get(chatObserverClaimedSeqNumsKey) ??
1270
+ new Map();
1271
+ locals.set(chatObserverClaimedSeqNumsKey, claims);
1272
+ const subscription = chatInputRouter().observe(CHAT_ROUTE_MESSAGES, (record) => {
1273
+ const payload = record.data.payload;
1274
+ const seqNum = record.seqNum;
1275
+ /**
1276
+ * Claimed synchronously, before any await, so a read cannot validate the
1277
+ * same record. The promise lets a read wait for the outcome rather than
1278
+ * spin on a record it is not allowed to take.
1279
+ */
1280
+ let release;
1281
+ const released = new Promise((resolve) => {
1282
+ release = resolve;
1283
+ });
1284
+ claims.set(record.seqNum, { released, release });
1285
+ const releaseClaim = () => {
1286
+ claims.delete(record.seqNum);
1287
+ release();
1288
+ };
1289
+ delivery = delivery
1290
+ .then(async () => {
1291
+ const result = await parseChatCustomAgentPayload(payload);
1292
+ if (!result.ok) {
1293
+ /**
1294
+ * Reported once, then taken off the channel.
1295
+ *
1296
+ * Observing does not consume, so without this the record would be
1297
+ * read again by the next turn and parsed a second time, firing a
1298
+ * user-supplied schema and reporting the same failure twice. A frame
1299
+ * that fails validation is never going to be answered by this run or
1300
+ * a later one, so removing it loses nothing that was still owed.
1301
+ */
1302
+ const timing = locals.get(chatCustomAgentClientDataErrorTimingKey) ?? "turn-end";
1303
+ const writeNow = timing === "arrival" || !active;
1304
+ await reportChatCustomAgentClientDataError(payload, result.error, {
1305
+ writeToStream: writeNow,
1306
+ });
1307
+ if (!writeNow) {
1308
+ const deferred = locals.get(chatCustomAgentDeferredClientDataErrorsKey) ?? [];
1309
+ deferred.push(payload);
1310
+ locals.set(chatCustomAgentDeferredClientDataErrorsKey, deferred);
1311
+ }
1312
+ // Drop the record: an invalid frame is never answered, by this run or
1313
+ // a later one. Released so a waiting read stops waiting, finds it
1314
+ // gone, and goes back to waiting for the next message.
1315
+ chatInputRouter().take(CHAT_ROUTE_MESSAGES, record.seqNum);
1316
+ releaseClaim();
1317
+ return;
1318
+ }
1319
+ // Valid, so release. The record is still queued, and whichever comes
1320
+ // first, an injection or a later turn's read, now owns it.
1321
+ releaseClaim();
1322
+ void Promise.resolve(handler(result.payload, seqNum, () => active)).catch(() => { });
1323
+ })
1324
+ .catch(() => { });
1325
+ });
1326
+ return {
1327
+ off() {
1328
+ active = false;
1329
+ subscription.off();
1330
+ },
1331
+ };
1332
+ }
1333
+ /**
1334
+ * Whether a record is currently owned by the validating observer.
1335
+ *
1336
+ * A read that pulls one puts it back and keeps waiting, so the observer's parse
1337
+ * stays the only one and the record is not consumed out from under it.
1338
+ */
1339
+ function observerClaimFor(record) {
1340
+ return locals.get(chatObserverClaimedSeqNumsKey)?.get(record.seqNum)?.released;
1341
+ }
1021
1342
  const messagesInput = {
1022
1343
  id: "chat-messages",
1023
1344
  on(handler) {
1024
- return getChatSession().in.on((chunk) => {
1025
- if (chunk.kind === "message") {
1026
- // Returning `true` marks the record CONSUMED at the manager level:
1027
- // it is neither buffered for a later `once()` nor re-delivered by
1028
- // the buffer drain when the next turn re-attaches its handler.
1029
- // Without this, a message arriving mid-stream was delivered twice
1030
- // and ran a duplicate turn.
1031
- void Promise.resolve(handler(chunk.payload)).catch(() => { });
1032
- return true;
1033
- }
1034
- return undefined;
1035
- });
1345
+ if (!locals.get(chatCustomAgentClientDataParserKey)) {
1346
+ return subscribeToRawChatMessages(handler);
1347
+ }
1348
+ const deliver = (payload) => handler(payload);
1349
+ return subscribeToValidatedChatMessages(deliver, { onAfterOff: deliver });
1036
1350
  },
1037
1351
  once(options) {
1038
- const ctx = taskContext.ctx;
1039
- const runId = ctx?.run.id;
1040
1352
  return new InputStreamOncePromise((resolve, reject) => {
1041
- tracer
1042
- .startActiveSpan(options?.spanName ?? `chat.messages.once()`, async () => {
1043
- while (true) {
1044
- const result = await getChatSession().in.once(options);
1045
- if (!result.ok) {
1046
- resolve(result);
1353
+ /**
1354
+ * Same skip-and-wait rule as `waitWithIdleTimeout`: a payload that fails
1355
+ * validation is reported and not surfaced. The timeout is a total budget
1356
+ * across retries, so skipping a frame cannot extend the wait forever.
1357
+ */
1358
+ const deadline = options?.timeoutMs === undefined ? undefined : Date.now() + options.timeoutMs;
1359
+ const take = () => {
1360
+ chatInputRouter()
1361
+ .next(CHAT_ROUTE_MESSAGES, {
1362
+ timeoutMs: deadline === undefined ? undefined : Math.max(0, deadline - Date.now()),
1363
+ })
1364
+ .then(async (record) => {
1365
+ if (!record) {
1366
+ resolve({
1367
+ ok: false,
1368
+ error: new InputStreamTimeoutError("chat-messages", options?.timeoutMs ?? 0),
1369
+ });
1047
1370
  return;
1048
1371
  }
1049
- if (result.output.kind === "message") {
1050
- resolve({ ok: true, output: result.output.payload });
1372
+ const claim = observerClaimFor(record);
1373
+ if (claim) {
1374
+ chatInputRouter().untake(CHAT_ROUTE_MESSAGES, record);
1375
+ await claim;
1376
+ take();
1051
1377
  return;
1052
1378
  }
1053
- // Non-message chunks (stops) are handled by the stopInput
1054
- // facade's persistent listener; loop and wait for the next.
1055
- }
1056
- }, {
1057
- attributes: {
1058
- [SemanticInternalAttributes.STYLE_ICON]: "streams",
1059
- [SemanticInternalAttributes.ENTITY_TYPE]: "input-stream",
1060
- ...(runId
1061
- ? {
1062
- [SemanticInternalAttributes.ENTITY_ID]: `${runId}:chat-messages`,
1063
- }
1064
- : {}),
1065
- streamId: "chat-messages",
1066
- ...accessoryAttributes({
1067
- items: [{ text: "chat-messages", variant: "normal" }],
1068
- style: "codepath",
1069
- }),
1070
- },
1071
- })
1072
- .catch(reject);
1379
+ const payload = record.data.payload;
1380
+ if (!shouldValidateChatCustomAgentPayload(payload)) {
1381
+ resolve({ ok: true, output: payload });
1382
+ return;
1383
+ }
1384
+ const validated = await validateChatCustomAgentPayload(payload);
1385
+ if (validated.ok) {
1386
+ resolve({ ok: true, output: validated.payload });
1387
+ return;
1388
+ }
1389
+ take();
1390
+ }, reject);
1391
+ };
1392
+ take();
1073
1393
  });
1074
1394
  },
1075
1395
  peek() {
1076
- const chunk = getChatSession().in.peek();
1077
- if (chunk && chunk.kind === "message")
1078
- return chunk.payload;
1079
- return undefined;
1396
+ const record = chatInputRouter().peek(CHAT_ROUTE_MESSAGES);
1397
+ if (!record)
1398
+ return undefined;
1399
+ const payload = record.data.payload;
1400
+ return validateChatCustomAgentPayloadSync(payload);
1401
+ },
1402
+ async hasPending() {
1403
+ return chatInputRouter().hasPending(CHAT_ROUTE_MESSAGES);
1404
+ },
1405
+ async next(options) {
1406
+ const timeoutInSeconds = options?.timeoutInSeconds;
1407
+ if (timeoutInSeconds !== undefined &&
1408
+ (!Number.isFinite(timeoutInSeconds) || timeoutInSeconds < 0)) {
1409
+ throw new TypeError("chat.messages.next() timeoutInSeconds must be a finite non-negative number");
1410
+ }
1411
+ // Consuming read, so it takes the same claim-and-validate path as the other
1412
+ // reads: a record the observer still owns is put back and awaited, and an
1413
+ // invalid payload is reported and skipped rather than surfaced raw.
1414
+ const totalMs = timeoutInSeconds === undefined ? undefined : timeoutInSeconds * 1000;
1415
+ /**
1416
+ * The caller's timeout is a total budget, not a per-attempt one. Skipping an
1417
+ * invalid frame must not buy another full wait, or a client sending invalid
1418
+ * frames faster than the timeout would keep the read blocked indefinitely
1419
+ * and it would never return.
1420
+ */
1421
+ const deadline = totalMs === undefined ? undefined : Date.now() + totalMs;
1422
+ while (true) {
1423
+ const record = await chatInputRouter().next(CHAT_ROUTE_MESSAGES, {
1424
+ timeoutMs: deadline === undefined ? undefined : Math.max(0, deadline - Date.now()),
1425
+ });
1426
+ if (!record)
1427
+ return undefined;
1428
+ const claim = observerClaimFor(record);
1429
+ if (claim) {
1430
+ chatInputRouter().untake(CHAT_ROUTE_MESSAGES, record);
1431
+ await claim;
1432
+ continue;
1433
+ }
1434
+ const chunk = record.data;
1435
+ if (!shouldValidateChatCustomAgentPayload(chunk.payload)) {
1436
+ return { id: record.id, seqNum: record.seqNum, payload: chunk.payload };
1437
+ }
1438
+ const validated = await validateChatCustomAgentPayload(chunk.payload);
1439
+ if (validated.ok) {
1440
+ return { id: record.id, seqNum: record.seqNum, payload: validated.payload };
1441
+ }
1442
+ }
1080
1443
  },
1081
1444
  wait(options) {
1082
1445
  return new ManualWaitpointPromise(async (resolve, reject) => {
1083
1446
  try {
1447
+ // Same skip-and-wait rule as `waitWithIdleTimeout`, looping back into
1448
+ // the suspending wait rather than surfacing an unvalidated payload.
1084
1449
  while (true) {
1085
- const result = await getChatSession().in.wait(options);
1450
+ const result = await waitOnChatRoute(CHAT_ROUTE_MESSAGES, { timeout: options?.timeout, spanName: options?.spanName });
1086
1451
  if (!result.ok) {
1087
- resolve(result);
1452
+ resolve({ ok: false, error: result.error ?? new Error("Timed out") });
1453
+ return;
1454
+ }
1455
+ const claim = observerClaimFor(result.record);
1456
+ if (claim) {
1457
+ chatInputRouter().untake(CHAT_ROUTE_MESSAGES, result.record);
1458
+ await claim;
1459
+ continue;
1460
+ }
1461
+ const payload = result.output.payload;
1462
+ if (!shouldValidateChatCustomAgentPayload(payload)) {
1463
+ resolve({ ok: true, output: payload });
1088
1464
  return;
1089
1465
  }
1090
- if (result.output.kind === "message") {
1091
- resolve({ ok: true, output: result.output.payload });
1466
+ const validated = await validateChatCustomAgentPayload(payload);
1467
+ if (validated.ok) {
1468
+ resolve({ ok: true, output: validated.payload });
1092
1469
  return;
1093
1470
  }
1094
- // Stop chunks are handled by the stopInput facade's persistent
1095
- // listener; loop back into the suspending wait.
1096
1471
  }
1097
1472
  }
1098
1473
  catch (error) {
@@ -1101,102 +1476,75 @@ const messagesInput = {
1101
1476
  });
1102
1477
  },
1103
1478
  async waitWithIdleTimeout(options) {
1479
+ /**
1480
+ * A payload that fails client-data validation is reported and skipped
1481
+ * rather than surfaced, so the caller never sees an unvalidated
1482
+ * `clientData`. The loop waits for the next message instead of returning an
1483
+ * error, which keeps a single bad client send from ending the turn.
1484
+ */
1104
1485
  while (true) {
1105
- const result = await getChatSession().in.waitWithIdleTimeout(options);
1486
+ const result = await waitOnChatRoute(CHAT_ROUTE_MESSAGES, options);
1106
1487
  if (!result.ok)
1107
- return result;
1108
- if (result.output.kind === "message") {
1109
- return { ok: true, output: result.output.payload };
1488
+ return { ok: false, error: result.error };
1489
+ const claim = observerClaimFor(result.record);
1490
+ if (claim) {
1491
+ chatInputRouter().untake(CHAT_ROUTE_MESSAGES, result.record);
1492
+ await claim;
1493
+ continue;
1494
+ }
1495
+ const payload = result.output.payload;
1496
+ if (!shouldValidateChatCustomAgentPayload(payload)) {
1497
+ return { ok: true, output: payload };
1498
+ }
1499
+ const validated = await validateChatCustomAgentPayload(payload);
1500
+ if (validated.ok) {
1501
+ return { ok: true, output: validated.payload };
1110
1502
  }
1111
- // Swallow stop-kind chunks — persistent stop listener already handled
1112
- // the abort; we just loop for the next message.
1113
1503
  }
1114
1504
  },
1115
1505
  async send(_runId, data, options) {
1116
- // The `runId` argument is kept for signature parity with
1117
- // `RealtimeDefinedInputStream` but ignored — sessions are addressed
1118
- // by sessionId, not runId. Callers producing messages from outside
1119
- // the run should prefer the transport's `session.in.send(...)` path.
1120
1506
  await getChatSession().in.send({ kind: "message", payload: data }, options?.requestOptions);
1121
1507
  },
1122
1508
  };
1123
1509
  const stopInput = {
1124
1510
  id: "chat-stop",
1125
1511
  on(handler) {
1126
- return getChatSession().in.on((chunk) => {
1127
- if (chunk.kind === "stop") {
1128
- // Consume stop records (see the messages facade above). A stop is
1129
- // only meaningful to the turn it interrupts — buffering it would
1130
- // let a stale stop abort a future turn.
1131
- void Promise.resolve(handler({ stop: true, message: chunk.message })).catch(() => { });
1132
- return true;
1133
- }
1134
- return undefined;
1512
+ return chatInputRouter().on(CHAT_ROUTE_STOP, (record) => {
1513
+ const chunk = record.data;
1514
+ void Promise.resolve(handler({ stop: true, message: chunk.message })).catch(() => { });
1135
1515
  });
1136
1516
  },
1137
1517
  once(options) {
1138
- const ctx = taskContext.ctx;
1139
- const runId = ctx?.run.id;
1140
1518
  return new InputStreamOncePromise((resolve, reject) => {
1141
- tracer
1142
- .startActiveSpan(options?.spanName ?? `chat.stop.once()`, async () => {
1143
- while (true) {
1144
- const result = await getChatSession().in.once(options);
1145
- if (!result.ok) {
1146
- resolve(result);
1147
- return;
1148
- }
1149
- if (result.output.kind === "stop") {
1150
- resolve({
1151
- ok: true,
1152
- output: { stop: true, message: result.output.message },
1153
- });
1154
- return;
1155
- }
1519
+ chatInputRouter()
1520
+ .next(CHAT_ROUTE_STOP, { timeoutMs: options?.timeoutMs })
1521
+ .then((record) => {
1522
+ if (!record) {
1523
+ resolve({
1524
+ ok: false,
1525
+ error: new InputStreamTimeoutError("chat-stop", options?.timeoutMs ?? 0),
1526
+ });
1527
+ return;
1156
1528
  }
1157
- }, {
1158
- attributes: {
1159
- [SemanticInternalAttributes.STYLE_ICON]: "streams",
1160
- [SemanticInternalAttributes.ENTITY_TYPE]: "input-stream",
1161
- ...(runId
1162
- ? {
1163
- [SemanticInternalAttributes.ENTITY_ID]: `${runId}:chat-stop`,
1164
- }
1165
- : {}),
1166
- streamId: "chat-stop",
1167
- ...accessoryAttributes({
1168
- items: [{ text: "chat-stop", variant: "normal" }],
1169
- style: "codepath",
1170
- }),
1171
- },
1172
- })
1173
- .catch(reject);
1529
+ const chunk = record.data;
1530
+ resolve({ ok: true, output: { stop: true, message: chunk.message } });
1531
+ }, reject);
1174
1532
  });
1175
1533
  },
1176
1534
  peek() {
1177
- const chunk = getChatSession().in.peek();
1178
- if (chunk && chunk.kind === "stop") {
1179
- return { stop: true, message: chunk.message };
1180
- }
1181
- return undefined;
1535
+ const record = chatInputRouter().peek(CHAT_ROUTE_STOP);
1536
+ if (!record)
1537
+ return undefined;
1538
+ const chunk = record.data;
1539
+ return { stop: true, message: chunk.message };
1182
1540
  },
1183
1541
  wait(options) {
1184
1542
  return new ManualWaitpointPromise(async (resolve, reject) => {
1185
1543
  try {
1186
- while (true) {
1187
- const result = await getChatSession().in.wait(options);
1188
- if (!result.ok) {
1189
- resolve(result);
1190
- return;
1191
- }
1192
- if (result.output.kind === "stop") {
1193
- resolve({
1194
- ok: true,
1195
- output: { stop: true, message: result.output.message },
1196
- });
1197
- return;
1198
- }
1199
- }
1544
+ const result = await waitOnChatRoute(CHAT_ROUTE_STOP, { timeout: options?.timeout, spanName: options?.spanName });
1545
+ resolve(result.ok
1546
+ ? { ok: true, output: { stop: true, message: result.output.message } }
1547
+ : { ok: false, error: result.error ?? new Error("Timed out") });
1200
1548
  }
1201
1549
  catch (error) {
1202
1550
  reject(error);
@@ -1204,14 +1552,10 @@ const stopInput = {
1204
1552
  });
1205
1553
  },
1206
1554
  async waitWithIdleTimeout(options) {
1207
- while (true) {
1208
- const result = await getChatSession().in.waitWithIdleTimeout(options);
1209
- if (!result.ok)
1210
- return result;
1211
- if (result.output.kind === "stop") {
1212
- return { ok: true, output: { stop: true, message: result.output.message } };
1213
- }
1214
- }
1555
+ const result = await waitOnChatRoute(CHAT_ROUTE_STOP, options);
1556
+ return result.ok
1557
+ ? { ok: true, output: { stop: true, message: result.output.message } }
1558
+ : { ok: false, error: result.error };
1215
1559
  },
1216
1560
  async send(_runId, data, options) {
1217
1561
  await getChatSession().in.send({ kind: "stop", message: data?.message }, options?.requestOptions);
@@ -1225,17 +1569,8 @@ const stopInput = {
1225
1569
  */
1226
1570
  const handoverInput = {
1227
1571
  async waitWithIdleTimeout(options) {
1228
- while (true) {
1229
- const result = await getChatSession().in.waitWithIdleTimeout(options);
1230
- if (!result.ok)
1231
- return result;
1232
- if (result.output.kind === "handover" || result.output.kind === "handover-skip") {
1233
- return { ok: true, output: result.output };
1234
- }
1235
- // Other kinds (message, stop) are not expected during handover-prepare.
1236
- // Loop back; the message and stop facades have their own listeners
1237
- // running so signals on those kinds aren't lost.
1238
- }
1572
+ const result = await waitOnChatRoute(CHAT_ROUTE_HANDOVER, options);
1573
+ return result.ok ? { ok: true, output: result.output } : result;
1239
1574
  },
1240
1575
  };
1241
1576
  /**
@@ -1251,22 +1586,229 @@ const handoverInput = {
1251
1586
  * For the common case prefer `accumulator.consumeHandover()`, which also seeds
1252
1587
  * `payload.headStartMessages` and applies the partial for you.
1253
1588
  *
1254
- * Must be called at turn 0 before any `chat.messages.waitWithIdleTimeout`
1255
- * that facade consumes and discards non-message chunks, which would swallow the
1256
- * handover signal.
1589
+ * Safe to call at any point in turn 0: the handover signal has its own route,
1590
+ * so a message facade waiting at the same time cannot take it.
1257
1591
  */
1258
1592
  async function waitForHandover(options) {
1259
1593
  if (options.payload.trigger !== "handover-prepare")
1260
1594
  return null;
1261
- const result = await handoverInput.waitWithIdleTimeout({
1262
- idleTimeoutInSeconds: options.idleTimeoutInSeconds ?? options.payload.idleTimeoutInSeconds ?? 60,
1263
- timeout: options.timeout,
1264
- spanName: options.spanName ?? "waiting for handover signal",
1595
+ try {
1596
+ const result = await handoverInput.waitWithIdleTimeout({
1597
+ idleTimeoutInSeconds: options.idleTimeoutInSeconds ?? options.payload.idleTimeoutInSeconds ?? 60,
1598
+ timeout: options.timeout,
1599
+ spanName: options.spanName ?? "waiting for handover signal",
1600
+ });
1601
+ // Non-ok = idle timeout or the warm handler crashed without signaling.
1602
+ if (!result.ok)
1603
+ return null;
1604
+ return result.output;
1605
+ }
1606
+ finally {
1607
+ chatInputRouter().clearRoute(CHAT_ROUTE_HANDOVER);
1608
+ }
1609
+ }
1610
+ /**
1611
+ * Everything `session.in` carries, and what happens to each kind.
1612
+ *
1613
+ * A route's two properties are what make the resume protocol derivable rather
1614
+ * than hand-maintained. `messages` is replayable because losing a user message
1615
+ * is data loss. `stop` is neither queued nor replayable: it only means anything
1616
+ * to the turn that is live when it lands, and a replayed one would abort
1617
+ * whichever turn happened to be running. `handover` is queued but not
1618
+ * replayable, because it can arrive before its consumer is ready yet is
1619
+ * meaningless to any later boot.
1620
+ *
1621
+ * A kind absent from this table has no consumer, so the router discards it
1622
+ * instead of letting it park at the head of a queue.
1623
+ * @internal
1624
+ */
1625
+ const CHAT_INPUT_ROUTES = {
1626
+ kindOf: (data) => data?.kind,
1627
+ routes: [
1628
+ { name: "messages", delivery: "queue", replayable: true, kinds: ["message"] },
1629
+ { name: "stop", delivery: "at-arrival", replayable: false, kinds: ["stop"] },
1630
+ {
1631
+ name: "handover",
1632
+ delivery: "queue",
1633
+ replayable: false,
1634
+ kinds: ["handover", "handover-skip"],
1635
+ },
1636
+ ],
1637
+ };
1638
+ const CHAT_ROUTE_MESSAGES = "messages";
1639
+ const CHAT_ROUTE_STOP = "stop";
1640
+ const CHAT_ROUTE_HANDOVER = "handover";
1641
+ /**
1642
+ * The `.in` router for the run this worker is currently serving.
1643
+ *
1644
+ * One slot rather than a map, because the facades have to reach the same router
1645
+ * the boot attached without depending on a locals scope being active. Tagged
1646
+ * with the run as well as the chat: a warm process is reused across runs and the
1647
+ * executor tears the channel subscription down at the end of each one, so
1648
+ * reusing a router across runs would leave the new run with no input at all. A
1649
+ * nested `chat.createSession` within the same run still shares it.
1650
+ * @internal
1651
+ */
1652
+ let currentChatInputRouter;
1653
+ /**
1654
+ * Both cursors from the latest `turn-complete` on `.out`, in one scan.
1655
+ *
1656
+ * Absent for a chat whose turns predate the headers, in which case the router
1657
+ * starts from the beginning of the channel and nothing is treated as replayed.
1658
+ * @internal
1659
+ */
1660
+ async function findLatestSessionInCheckpoint(chatId) {
1661
+ const apiClient = apiClientManager.clientOrThrow();
1662
+ const response = await apiClient.readSessionStreamRecords(chatId, "out");
1663
+ const checkpoint = {};
1664
+ for (const record of response.records) {
1665
+ if (controlSubtype(record.headers) !== TRIGGER_CONTROL_SUBTYPE.TURN_COMPLETE)
1666
+ continue;
1667
+ const resumeFrom = Number.parseInt(headerValue(record.headers, SESSION_IN_EVENT_ID_HEADER) ?? "", 10);
1668
+ if (Number.isFinite(resumeFrom))
1669
+ checkpoint.resumeFrom = resumeFrom;
1670
+ const appliedThrough = Number.parseInt(headerValue(record.headers, SESSION_IN_CONSUMED_ID_HEADER) ?? "", 10);
1671
+ if (Number.isFinite(appliedThrough))
1672
+ checkpoint.appliedThrough = appliedThrough;
1673
+ }
1674
+ return checkpoint;
1675
+ }
1676
+ /**
1677
+ * The highest `.in` sequence already on the channel at boot, above the floor.
1678
+ *
1679
+ * Everything already on the channel when a run boots is, by definition, not
1680
+ * arriving live on this run, so the channel's own tail is the end of this run's
1681
+ * replay window.
1682
+ *
1683
+ * The turn boundary's value is not enough on its own. A boundary is written when
1684
+ * a turn ends, so a control record that arrived after the last boundary is not
1685
+ * covered by it, and a boundary written by an older SDK does not carry one at
1686
+ * all. Both cases leave an already-applied record looking live.
1687
+ *
1688
+ * Bounded by `afterEventId` when a floor is known, which is the common case, so
1689
+ * the read covers the replay window rather than the conversation. An absent
1690
+ * floor means no boundary has committed a cursor yet, and the channel is still
1691
+ * short.
1692
+ *
1693
+ * Returns `undefined` if the read fails; the caller then falls back to the
1694
+ * floor, which is the previous release's behaviour.
1695
+ * @internal
1696
+ */
1697
+ async function findSessionInReplayWindowEnd(chatId, afterSeqNum) {
1698
+ try {
1699
+ const apiClient = apiClientManager.clientOrThrow();
1700
+ const response = await apiClient.readSessionStreamRecords(chatId, "in", {
1701
+ ...(afterSeqNum === undefined ? {} : { afterEventId: String(afterSeqNum) }),
1702
+ });
1703
+ let highest;
1704
+ for (const record of response.records) {
1705
+ const seqNum = typeof record.seqNum === "number" ? record.seqNum : Number.NaN;
1706
+ if (Number.isFinite(seqNum) && (highest === undefined || seqNum > highest)) {
1707
+ highest = seqNum;
1708
+ }
1709
+ }
1710
+ return highest;
1711
+ }
1712
+ catch {
1713
+ return undefined;
1714
+ }
1715
+ }
1716
+ /**
1717
+ * Attach the `.in` router for this run.
1718
+ *
1719
+ * Reads the checkpoint and subscribes in one call, so there is no window in
1720
+ * which a listener is attached before the resume cursor is seeded. Attaching
1721
+ * first would open the tail at sequence 0 and replay every record the previous
1722
+ * run already answered, which is a mistake the previous shape of this code made
1723
+ * possible and this shape does not.
1724
+ *
1725
+ * The router consumes every record at dispatch, so the channel's own buffer
1726
+ * stays empty for chat and its cursor bookkeeping never engages. Delivery,
1727
+ * ordering and the published cursors are the router's, entirely.
1728
+ * @internal
1729
+ */
1730
+ async function installChatInputRouter(chatId, options) {
1731
+ const entry = chatInputRouterEntry(chatId);
1732
+ if (entry.attached)
1733
+ return entry.router;
1734
+ let checkpoint = {};
1735
+ try {
1736
+ checkpoint = await findLatestSessionInCheckpoint(chatId);
1737
+ }
1738
+ catch (error) {
1739
+ logger.warn("chat session: session.in resume cursor lookup failed; old messages may replay", {
1740
+ error: error instanceof Error ? error.message : String(error),
1741
+ });
1742
+ }
1743
+ if (checkpoint.resumeFrom === undefined && options?.fallbackResumeFrom !== undefined) {
1744
+ checkpoint.resumeFrom = options.fallbackResumeFrom;
1745
+ }
1746
+ // Only a resuming run has a replay window. On a first boot nothing has been
1747
+ // applied by anyone, so treating what is already on the channel as replayed
1748
+ // would discard a signal that arrived before the agent got here, which is
1749
+ // exactly how a head-start handover reaches a cold run.
1750
+ const resuming = checkpoint.resumeFrom !== undefined || options?.resuming === true;
1751
+ if (resuming) {
1752
+ const replayWindowEnd = await findSessionInReplayWindowEnd(chatId, checkpoint.resumeFrom);
1753
+ if (replayWindowEnd !== undefined) {
1754
+ checkpoint.appliedThrough = Math.max(checkpoint.appliedThrough ?? replayWindowEnd, replayWindowEnd);
1755
+ }
1756
+ }
1757
+ const router = entry.router;
1758
+ router.restore(checkpoint);
1759
+ const floor = router.resumeFrom();
1760
+ if (floor !== undefined) {
1761
+ sessionStreams.setLastSeqNum(chatId, "in", floor);
1762
+ sessionStreams.setLastDispatchedSeqNum(chatId, "in", floor);
1763
+ }
1764
+ sessionStreams.onRecord(chatId, "in", (record) => {
1765
+ router.ingest(record);
1766
+ return true;
1265
1767
  });
1266
- // Non-ok = idle timeout or the warm handler crashed without signaling.
1267
- if (!result.ok)
1268
- return null;
1269
- return result.output;
1768
+ entry.attached = true;
1769
+ return router;
1770
+ }
1771
+ function chatInputRouterEntry(chatId) {
1772
+ const runId = taskContext.ctx?.run.id;
1773
+ if (currentChatInputRouter?.chatId === chatId && currentChatInputRouter.runId === runId) {
1774
+ return currentChatInputRouter;
1775
+ }
1776
+ currentChatInputRouter = {
1777
+ chatId,
1778
+ runId,
1779
+ router: new SessionChannelRouter(CHAT_INPUT_ROUTES, {
1780
+ onDrop: (record, reason) => {
1781
+ if (reason === "unroutable" || reason === "malformed") {
1782
+ logger.warn("chat: discarded a session.in record nothing on this worker can consume", {
1783
+ reason,
1784
+ seqNum: record.seqNum,
1785
+ });
1786
+ }
1787
+ },
1788
+ }),
1789
+ attached: false,
1790
+ };
1791
+ return currentChatInputRouter;
1792
+ }
1793
+ /** Drop the router so the next boot attaches a fresh one. @internal */
1794
+ export function __resetChatInputRouterForTests() {
1795
+ currentChatInputRouter = undefined;
1796
+ }
1797
+ /** The cursors this run would publish on its next turn boundary. @internal */
1798
+ export function __chatInputCheckpointForTests() {
1799
+ return currentChatInputRouter?.router.checkpoint() ?? {};
1800
+ }
1801
+ /** Test-only entry point for the turn-boundary cursor scan. @internal */
1802
+ export async function __findLatestSessionInCheckpointForTests(chatId) {
1803
+ return findLatestSessionInCheckpoint(chatId);
1804
+ }
1805
+ /**
1806
+ * This chat's router. Created on first use so a facade reached before the
1807
+ * install still shares the one the install will attach.
1808
+ * @internal
1809
+ */
1810
+ function chatInputRouter() {
1811
+ return chatInputRouterEntry(getChatSession().id).router;
1270
1812
  }
1271
1813
  /**
1272
1814
  * Per-turn deferred promises. Registered via `chat.defer()`, awaited
@@ -1862,6 +2404,30 @@ const chatCompactionStateKey = locals.create("chat.compaction");
1862
2404
  const chatOnCompactedKey = locals.create("chat.onCompacted");
1863
2405
  /** @internal Full task `ctx` for the active `chat.agent` run (for hooks invoked from nested compaction). */
1864
2406
  const chatAgentRunContextKey = locals.create("chat.agentRunContext");
2407
+ /** @internal When a mid-turn validation failure writes its terminal error. */
2408
+ const chatCustomAgentClientDataErrorTimingKey = locals.create("chat.customAgent.clientDataErrorTiming");
2409
+ /**
2410
+ * @internal Client-data errors held back until the open turn closes.
2411
+ *
2412
+ * The default timing exists so a bad send cannot truncate an answer already
2413
+ * being read, which means the write has to happen later rather than not at all:
2414
+ * the callback and the task log are server-side, so dropping the stream write
2415
+ * would leave the client with no signal that its frame was rejected.
2416
+ */
2417
+ const chatCustomAgentDeferredClientDataErrorsKey = locals.create("chat.customAgent.deferredClientDataErrors");
2418
+ /**
2419
+ * @internal Sequences the validating observer has claimed.
2420
+ *
2421
+ * The observer watches without consuming, so a record it is still validating is
2422
+ * also visible to a turn's read. Claiming makes ownership single: a read that
2423
+ * pulls a claimed record puts it back and keeps waiting, so exactly one of them
2424
+ * validates it and the schema runs once per frame.
2425
+ */
2426
+ const chatObserverClaimedSeqNumsKey = locals.create("chat.customAgent.observerClaims");
2427
+ /** @internal Marks the root run created by `chat.customAgent()`. */
2428
+ const chatCustomAgentRunKey = locals.create("chat.customAgentRun");
2429
+ /** @internal Number of active `chat.createSession()` iterators in this run. */
2430
+ const chatActiveSessionIteratorsKey = locals.create("chat.createSession.activeIterators");
1865
2431
  const chatPrepareMessagesKey = locals.create("chat.prepareMessages");
1866
2432
  /**
1867
2433
  * @internal The raw `tools` option from `chat.agent({ tools })`, either a
@@ -2267,7 +2833,14 @@ async function drainSteeringQueue(config, messages, steps, queueOverride) {
2267
2833
  return [];
2268
2834
  const ctx = locals.get(chatTurnContextKey);
2269
2835
  const stepNumber = steps.length - 1;
2270
- const uiMessages = queue.map((e) => e.uiMessage);
2836
+ /**
2837
+ * Snapshot, because `shouldInject` and `prepare` can await. A record arriving
2838
+ * during either is not in this batch, so it must not be consumed by it: the
2839
+ * callbacks never saw it and could not have injected it, and taking it would
2840
+ * lose a message that should become a later turn instead.
2841
+ */
2842
+ const batch = [...queue];
2843
+ const uiMessages = batch.map((e) => e.uiMessage);
2271
2844
  const batchEvent = {
2272
2845
  messages: uiMessages,
2273
2846
  modelMessages: messages,
@@ -2281,22 +2854,79 @@ async function drainSteeringQueue(config, messages, steps, queueOverride) {
2281
2854
  const shouldInject = config.shouldInject ? await config.shouldInject(batchEvent) : false;
2282
2855
  if (!shouldInject)
2283
2856
  return [];
2284
- // Extract message texts for span attributes
2285
- const messageTexts = uiMessages.map((m) => (m.parts ?? [])
2857
+ const textOfUIMessage = (m) => (m.parts ?? [])
2286
2858
  .filter((p) => p.type === "text")
2287
2859
  .map((p) => p.text)
2288
- .join("") || "");
2860
+ .join("") || "";
2861
+ // Span attributes describe the offered batch; the chunk and callback below
2862
+ // describe what was actually claimed and injected.
2863
+ const messageTexts = uiMessages.map(textOfUIMessage);
2289
2864
  const _previewText = messageTexts.length === 1 ? messageTexts[0].slice(0, 80) : `${queue.length} messages`;
2290
2865
  return tracer.startActiveSpan("pending message injected", async () => {
2291
- // Transform the batch — default: concatenate all pre-converted model messages
2292
- const injected = config.prepare
2293
- ? await config.prepare(batchEvent)
2294
- : queue.flatMap((e) => e.modelMessages);
2295
- // Clear the queue and record injected IDs
2296
- queue.length = 0;
2866
+ /**
2867
+ * Claim before transforming, and inject only what was claimed.
2868
+ *
2869
+ * Injection is the point of consumption: the records were only observed
2870
+ * on arrival, so they are still queued on the router and still holding
2871
+ * the resume floor, and taking them here is what stops the same message
2872
+ * also being answered as a later turn. A declined batch never reaches
2873
+ * this line, so its records stay queued and become later turns, which is
2874
+ * what "messages queue for the next turn" means.
2875
+ *
2876
+ * A failed claim means something else already consumed that record while
2877
+ * `shouldInject` was awaiting, so it is already being answered as a turn
2878
+ * of its own. Injecting it as well would process the same message twice.
2879
+ * An entry with no `seqNum` did not come from a channel record (the
2880
+ * accumulator's own queue), so there is nothing to claim and it is kept.
2881
+ */
2882
+ const router = chatInputRouter();
2883
+ const claimed = [];
2884
+ const takenRecords = [];
2885
+ for (const entry of batch) {
2886
+ if (entry.seqNum === undefined) {
2887
+ claimed.push(entry);
2888
+ continue;
2889
+ }
2890
+ const record = router.take(CHAT_ROUTE_MESSAGES, entry.seqNum);
2891
+ if (!record)
2892
+ continue;
2893
+ takenRecords.push(record);
2894
+ claimed.push(entry);
2895
+ }
2896
+ for (const entry of claimed) {
2897
+ const at = queue.indexOf(entry);
2898
+ if (at !== -1)
2899
+ queue.splice(at, 1);
2900
+ }
2901
+ if (claimed.length === 0)
2902
+ return [];
2903
+ /**
2904
+ * Give the claim back if the transform fails. `prepare` is caller code and
2905
+ * can throw; the records have already left the router by this point, so
2906
+ * without returning them a failed transform would consume the messages and
2907
+ * they would never be answered at all.
2908
+ */
2909
+ const releaseClaim = () => {
2910
+ for (const record of takenRecords)
2911
+ router.untake(CHAT_ROUTE_MESSAGES, record);
2912
+ for (const entry of claimed)
2913
+ if (!queue.includes(entry))
2914
+ queue.push(entry);
2915
+ };
2916
+ const claimedUIMessages = claimed.map((e) => e.uiMessage);
2917
+ let injected;
2918
+ try {
2919
+ injected = config.prepare
2920
+ ? await config.prepare({ ...batchEvent, messages: claimedUIMessages })
2921
+ : claimed.flatMap((e) => e.modelMessages);
2922
+ }
2923
+ catch (err) {
2924
+ releaseClaim();
2925
+ throw err;
2926
+ }
2297
2927
  const injectedIds = locals.get(chatInjectedMessageIdsKey);
2298
2928
  if (injectedIds) {
2299
- for (const m of uiMessages)
2929
+ for (const m of claimedUIMessages)
2300
2930
  injectedIds.add(m.id);
2301
2931
  }
2302
2932
  // Write injection confirmation chunk to the stream so the frontend
@@ -2310,10 +2940,10 @@ async function drainSteeringQueue(config, messages, steps, queueOverride) {
2310
2940
  type: PENDING_MESSAGE_INJECTED_TYPE,
2311
2941
  id: generateMessageId(),
2312
2942
  data: {
2313
- messageIds: uiMessages.map((m) => m.id),
2314
- messages: uiMessages.map((m, idx) => ({
2943
+ messageIds: claimedUIMessages.map((m) => m.id),
2944
+ messages: claimedUIMessages.map((m) => ({
2315
2945
  id: m.id,
2316
- text: messageTexts[idx] ?? "",
2946
+ text: textOfUIMessage(m),
2317
2947
  })),
2318
2948
  },
2319
2949
  });
@@ -2329,7 +2959,7 @@ async function drainSteeringQueue(config, messages, steps, queueOverride) {
2329
2959
  if (config.onInjected && injected.length > 0) {
2330
2960
  try {
2331
2961
  await config.onInjected({
2332
- messages: uiMessages,
2962
+ messages: claimedUIMessages,
2333
2963
  injectedModelMessages: injected,
2334
2964
  chatId: ctx?.chatId ?? "",
2335
2965
  turn: ctx?.turn ?? 0,
@@ -2763,7 +3393,11 @@ async function pipeChat(source, options) {
2763
3393
  await waitUntilComplete();
2764
3394
  }
2765
3395
  function chatCustomAgent(options) {
2766
- const { clientDataSchema, run: userRun, ...restOptions } = options;
3396
+ const { clientDataSchema, onClientDataValidationError, clientDataReportErrorAt, run: userRun, ...restOptions } = options;
3397
+ const parseClientData = clientDataSchema ? getSchemaParseFn(clientDataSchema) : undefined;
3398
+ const parseClientDataSync = clientDataSchema
3399
+ ? getChatCustomAgentSyncSchemaParseFn(clientDataSchema)
3400
+ : undefined;
2767
3401
  const task = createTask({
2768
3402
  ...restOptions,
2769
3403
  triggerSource: "agent",
@@ -2782,6 +3416,17 @@ function chatCustomAgent(options) {
2782
3416
  locals.set(chatSessionHandleKey, sessions.open(payload.chatId));
2783
3417
  locals.set(chatExternalIdKey, payload.chatId);
2784
3418
  locals.set(chatAgentRunContextKey, runOptions.ctx);
3419
+ locals.set(chatCustomAgentRunKey, true);
3420
+ if (parseClientData && parseClientDataSync) {
3421
+ locals.set(chatCustomAgentClientDataParserKey, {
3422
+ parse: parseClientData,
3423
+ parseSync: parseClientDataSync,
3424
+ });
3425
+ }
3426
+ if (onClientDataValidationError) {
3427
+ locals.set(chatCustomAgentClientDataErrorHandlerKey, onClientDataValidationError);
3428
+ }
3429
+ locals.set(chatCustomAgentClientDataErrorTimingKey, clientDataReportErrorAt ?? "turn-end");
2785
3430
  // Initialize the turn-complete trim slot so `chat.writeTurnComplete`
2786
3431
  // trims `session.out` back to the previous turn boundary. Without
2787
3432
  // this the slot is undefined and the trim never runs, so `.out`
@@ -2790,11 +3435,65 @@ function chatCustomAgent(options) {
2790
3435
  markChatAgentRunForStreamsWarning();
2791
3436
  taskContext.setConversationId(payload.chatId);
2792
3437
  stampConversationIdOnActiveSpan(payload.chatId);
2793
- // Seed the `.in` resume cursor before user code attaches any `.in`
2794
- // listener — otherwise a continuation boot replays already-answered
2795
- // messages into the loop's first wait.
2796
- await seedSessionInResumeCursorForCustomLoop(payload);
2797
- return userRun(payload, runOptions);
3438
+ await installChatInputRouter(payload.chatId, {
3439
+ resuming: Boolean(payload.continuation),
3440
+ });
3441
+ // Keep the schema-free path identical to the original custom-agent
3442
+ // wrapper, including when userRun starts executing.
3443
+ if (!parseClientData) {
3444
+ return userRun(payload, runOptions);
3445
+ }
3446
+ const isHandoverBoot = payload.trigger === "handover-prepare";
3447
+ const isMessagelessBoot = payload.trigger === "preload" ||
3448
+ (payload.continuation === true &&
3449
+ payload.message === undefined &&
3450
+ payload.trigger !== "action" &&
3451
+ payload.trigger !== "regenerate-message" &&
3452
+ !isHandoverBoot);
3453
+ const validated = await validateChatCustomAgentPayload(payload, {
3454
+ // Preload and continuation boots do not represent a submitted turn,
3455
+ // so there is no sender waiting for a terminal frame. Handover errors
3456
+ // must be written after the warm response flushes and signals below.
3457
+ writeErrorToStream: !isMessagelessBoot && !isHandoverBoot,
3458
+ });
3459
+ if (validated.ok) {
3460
+ return userRun(validated.payload, runOptions);
3461
+ }
3462
+ if (isHandoverBoot) {
3463
+ const signal = await waitForHandover({
3464
+ payload,
3465
+ timeout: "1h",
3466
+ spanName: "waiting for handover signal (invalid clientData)",
3467
+ });
3468
+ if (!signal || signal.kind === "handover-skip") {
3469
+ return;
3470
+ }
3471
+ // The head-start writer flushes before sending this signal. Writing
3472
+ // the terminal error now preserves stream order and closes the stitch.
3473
+ await writeChatCustomAgentClientDataErrorToStream(payload);
3474
+ return;
3475
+ }
3476
+ // The Session base payload is sticky across continuation runs. If it is
3477
+ // invalid, returning here would boot the same bad metadata again on the
3478
+ // next message. Stay attached and wait for a valid wire frame instead.
3479
+ const next = await messagesInput.waitWithIdleTimeout({
3480
+ idleTimeoutInSeconds: payload.idleTimeoutInSeconds ?? 30,
3481
+ timeout: "1h",
3482
+ spanName: "waiting for valid clientData",
3483
+ });
3484
+ if (!next.ok || next.output.trigger === "close") {
3485
+ return;
3486
+ }
3487
+ // Normal input frames omit run-level boot context. Carry it forward so
3488
+ // a continuation still tells the custom loop to restore prior state.
3489
+ const recoveredPayload = {
3490
+ ...next.output,
3491
+ continuation: next.output.continuation ?? payload.continuation,
3492
+ previousRunId: next.output.previousRunId ?? payload.previousRunId,
3493
+ sessionId: next.output.sessionId ?? payload.sessionId,
3494
+ idleTimeoutInSeconds: next.output.idleTimeoutInSeconds ?? payload.idleTimeoutInSeconds,
3495
+ };
3496
+ return userRun(recoveredPayload, runOptions);
2798
3497
  },
2799
3498
  });
2800
3499
  // Register clientDataSchema so the CLI converts it to JSONSchema
@@ -2824,6 +3523,7 @@ function chatAgent(options) {
2824
3523
  agentConfig: { type: "ai-sdk-chat" },
2825
3524
  run: async (payload, { signal: runSignal, ctx }) => {
2826
3525
  locals.set(chatAgentRunContextKey, ctx);
3526
+ locals.set(chatCustomAgentRunKey, false);
2827
3527
  // On AI SDK 7, register the `@ai-sdk/otel` integration (once per process)
2828
3528
  // so `experimental_telemetry` spans flow into the run trace. Awaited here
2829
3529
  // at run boot — before any `streamText` — and a no-op on v5/v6 or when the
@@ -2916,11 +3616,6 @@ function chatAgent(options) {
2916
3616
  // default, `inFlightUsers`). The turn-loop checks this queue ahead of
2917
3617
  // `messagesInput.waitWithIdleTimeout` so recovered turns fire first.
2918
3618
  const bootInjectedQueue = [];
2919
- // Messages consumed by a turn's `messagesInput.on` handler, dispatched
2920
- // one per turn by the end-of-turn pickup. Loop-level on purpose:
2921
- // consuming a record advances the committed `.in` cursor, so entries
2922
- // dropped with a turn-local buffer are lost permanently.
2923
- const pendingWireMessages = [];
2924
3619
  const couldHavePriorState = payload.continuation === true || ctx.attempt.number > 1;
2925
3620
  // `.in` resume cursor, computed at most once per boot. The boot
2926
3621
  // block below resolves it (snapshot field or records scan) and the
@@ -3043,49 +3738,16 @@ function chatAgent(options) {
3043
3738
  },
3044
3739
  });
3045
3740
  }
3046
- // ── session.in resume cursor ───────────────────────────────────
3047
- //
3048
- // A fresh worker subscribes to `session.in` from seq 0 and would
3049
- // re-deliver every record ever appended — including user messages
3050
- // from turns already completed on a prior run. Without a cursor,
3051
- // the loop would re-process them as fresh turns and the slim-wire
3052
- // merge would replace-by-id against snapshot-restored copies,
3053
- // yielding no-op replaces while the customer's actual new message
3054
- // waits in the queue.
3741
+ // ── session.in router ──────────────────────────────────────────
3055
3742
  //
3056
- // The cursor is the seq_num of the last `.in` record the prior
3057
- // worker committed to processing, persisted on each `turn-complete`
3058
- // control record as a `session-in-event-id` sibling header. The
3059
- // boot scan reads the header off `.out`'s latest turn-complete and
3060
- // seeds the manager so the upcoming `.in` SSE subscribe opens with
3061
- // `Last-Event-ID: <cursor>` S2 starts after that seq and old
3062
- // messages never reach this worker.
3063
- //
3064
- // Applies in three cases (any of which means `.in` has records
3065
- // belonging to completed turns the new run should skip):
3066
- // - OOM retry (`ctx.attempt.number > 1`)
3067
- // - Continuation run (`payload.continuation === true`) — prior run
3068
- // crashed / was canceled / requested upgrade
3069
- // - Snapshot exists at all (catches edge cases where the wire
3070
- // didn't set `continuation` but a snapshot indicates prior turns)
3071
- const needsResumeCursor = ctx.attempt.number > 1 || payload.continuation === true || bootSnapshot !== undefined;
3072
- if (needsResumeCursor) {
3073
- try {
3074
- // Reuse the cursor the boot block already resolved (snapshot
3075
- // field or records scan) — only scan here when the boot block
3076
- // was skipped (hydrateMessages, or snapshot-only signals).
3077
- const cursor = bootInCursorResolved
3078
- ? bootInCursor
3079
- : await findLatestSessionInCursor(payload.chatId);
3080
- if (cursor !== undefined) {
3081
- sessionStreams.setLastSeqNum(payload.chatId, "in", cursor);
3082
- sessionStreams.setLastDispatchedSeqNum(payload.chatId, "in", cursor);
3083
- }
3084
- }
3085
- catch (error) {
3086
- logger.warn("chat.agent: session.in resume cursor lookup failed; old messages may replay", { error: error instanceof Error ? error.message : String(error) });
3087
- }
3088
- }
3743
+ // Reads the turn boundary and subscribes in one call. `bootInCursor` is
3744
+ // only a fallback: the boot block above may already have resolved a
3745
+ // cursor from the snapshot, which is used when the boundary itself
3746
+ // carries none.
3747
+ await installChatInputRouter(payload.chatId, {
3748
+ fallbackResumeFrom: bootInCursorResolved ? bootInCursor : undefined,
3749
+ resuming: Boolean(payload.continuation) || ctx.attempt.number > 1,
3750
+ });
3089
3751
  // ── Recovery boot + chain reconstruction ────────────────────────
3090
3752
  if (!hydrateMessages) {
3091
3753
  const settledMessages = mergeByIdReplaceWins(bootSnapshot?.messages ?? [], replayedSettled);
@@ -3154,14 +3816,23 @@ function chatAgent(options) {
3154
3816
  hookBeforeBoot = hookResult.beforeBoot;
3155
3817
  }
3156
3818
  }
3157
- // Default: splice partial + the user it was answering into
3158
- // the chain so follow-ups like "keep going" still have context.
3819
+ // Default: splice partial + the user it was answering into the chain
3820
+ // so follow-ups like "keep going" still have context, and re-dispatch
3821
+ // the users that arrived after it.
3822
+ //
3823
+ // The splice needs a follow-up user to answer — it consumes
3824
+ // `inFlightUsers[0]` into the chain instead of dispatching it. With
3825
+ // exactly ONE in-flight user (the plain OOM / crash-mid-answer case)
3826
+ // there is nothing left to dispatch, so splicing would strand that
3827
+ // user unanswered and idle the run. Require `length > 1` on both
3828
+ // branches: at n=1 the orphan partial is dropped and the interrupted
3829
+ // user is re-dispatched as a fresh turn instead.
3159
3830
  let seedChain;
3160
3831
  let recoveredTurns;
3161
3832
  if (hookChain !== undefined) {
3162
3833
  seedChain = hookChain;
3163
3834
  }
3164
- else if (partialAssistant !== undefined && inFlightUsers.length > 0) {
3835
+ else if (partialAssistant !== undefined && inFlightUsers.length > 1) {
3165
3836
  seedChain = [...settledMessages, inFlightUsers[0], partialAssistant];
3166
3837
  }
3167
3838
  else {
@@ -3170,7 +3841,7 @@ function chatAgent(options) {
3170
3841
  if (hookRecoveredTurns !== undefined) {
3171
3842
  recoveredTurns = hookRecoveredTurns;
3172
3843
  }
3173
- else if (partialAssistant !== undefined && inFlightUsers.length > 0) {
3844
+ else if (partialAssistant !== undefined && inFlightUsers.length > 1) {
3174
3845
  recoveredTurns = inFlightUsers.slice(1);
3175
3846
  }
3176
3847
  else {
@@ -3723,11 +4394,18 @@ function chatAgent(options) {
3723
4394
  const cancelSignal = runSignal;
3724
4395
  const combinedSignal = AbortSignal.any([runSignal, stopController.signal]);
3725
4396
  const pmConfig = locals.get(chatPendingMessagesKey);
3726
- const msgSub = messagesInput.on(async (msg) => {
3727
- // If pendingMessages is configured, route to the steering queue
3728
- // instead of the wire buffer. The frontend handles re-sending
3729
- // non-injected messages via sendMessage on turn complete.
3730
- if (pmConfig) {
4397
+ /**
4398
+ * Only attached when there is a steering config to feed. Without
4399
+ * one a mid-turn message is left queued on the router, which is
4400
+ * what holds the resume floor behind it: a record handed to a
4401
+ * handler counts as terminally decided, so buffering one here
4402
+ * published a cursor past a message held only in memory and a
4403
+ * crash before the next turn lost it.
4404
+ */
4405
+ const msgSub = pmConfig
4406
+ ? chatInputRouter().observe(CHAT_ROUTE_MESSAGES, async (record) => {
4407
+ const msg = record.data
4408
+ .payload;
3731
4409
  // Slim wire: at most one delta message per record. The
3732
4410
  // pendingMessages handler reads `msg.message` directly
3733
4411
  // instead of slicing an array — a wire record arrives
@@ -3758,6 +4436,7 @@ function chatAgent(options) {
3758
4436
  queue.push({
3759
4437
  uiMessage: lastUIMessage,
3760
4438
  modelMessages: modelMsgs,
4439
+ seqNum: record.seqNum,
3761
4440
  });
3762
4441
  locals.set(chatSteeringQueueKey, queue);
3763
4442
  }
@@ -3765,11 +4444,8 @@ function chatAgent(options) {
3765
4444
  /* conversion failed — skip steering queue */
3766
4445
  }
3767
4446
  }
3768
- return; // Don't add to wire buffer — frontend handles non-injected case
3769
- }
3770
- // No pendingMessages config — standard wire buffer for next turn
3771
- pendingWireMessages.push(msg);
3772
- });
4447
+ })
4448
+ : undefined;
3773
4449
  turnMsgSub = msgSub;
3774
4450
  // Track new messages for this turn (user input + assistant response).
3775
4451
  const turnNewModelMessages = [];
@@ -4091,7 +4767,7 @@ function chatAgent(options) {
4091
4767
  // The turn counter is decremented so the next iteration
4092
4768
  // sees the same `turn` value — actions don't count.
4093
4769
  if (isAction) {
4094
- msgSub.off();
4770
+ msgSub?.off();
4095
4771
  if ((locals.get(chatPipeCountKey) ?? 0) === 0 &&
4096
4772
  isUIMessageStreamable(actionStreamResult)) {
4097
4773
  try {
@@ -4348,7 +5024,7 @@ function chatAgent(options) {
4348
5024
  }
4349
5025
  }
4350
5026
  finally {
4351
- msgSub.off();
5027
+ msgSub?.off();
4352
5028
  }
4353
5029
  // Wait for onFinish to fire — on abort this may resolve slightly
4354
5030
  // after pipeChat, since the stream's cancel() handler is async.
@@ -4792,7 +5468,7 @@ function chatAgent(options) {
4792
5468
  if (!hydrateMessages) {
4793
5469
  try {
4794
5470
  await tracer.startActiveSpan("snapshot.write", async () => {
4795
- const snapshotInCursor = getChatSession().in.lastDispatchedSeqNum();
5471
+ const snapshotInCursor = chatInputRouter().resumeFloor();
4796
5472
  await writeChatSnapshot(sessionIdForSnapshot, {
4797
5473
  version: 1,
4798
5474
  savedAt: Date.now(),
@@ -4831,13 +5507,6 @@ function chatAgent(options) {
4831
5507
  currentWirePayload = bootInjectedQueue.shift();
4832
5508
  return "continue";
4833
5509
  }
4834
- // If messages arrived during streaming (without pendingMessages config),
4835
- // dispatch the oldest as the next turn. The rest stay queued
4836
- // and drain one per turn.
4837
- if (pendingWireMessages.length > 0) {
4838
- currentWirePayload = pendingWireMessages.shift();
4839
- return "continue";
4840
- }
4841
5510
  // chat.requestUpgrade() was called — exit the loop so the
4842
5511
  // transport triggers a new run on the latest version.
4843
5512
  // chat.endRun() — same exit, no upgrade semantics.
@@ -5071,7 +5740,7 @@ function chatAgent(options) {
5071
5740
  // neither the snapshot nor the replayable `.in` tail.
5072
5741
  if (!hydrateMessages) {
5073
5742
  try {
5074
- const errorSnapshotInCursor = getChatSession().in.lastDispatchedSeqNum();
5743
+ const errorSnapshotInCursor = chatInputRouter().resumeFloor();
5075
5744
  await writeChatSnapshot(sessionIdForSnapshot, {
5076
5745
  version: 1,
5077
5746
  savedAt: Date.now(),
@@ -5098,11 +5767,6 @@ function chatAgent(options) {
5098
5767
  currentWirePayload = bootInjectedQueue.shift();
5099
5768
  continue;
5100
5769
  }
5101
- // Same for messages buffered during the errored turn — already consumed, idling strands them.
5102
- if (pendingWireMessages.length > 0) {
5103
- currentWirePayload = pendingWireMessages.shift();
5104
- continue;
5105
- }
5106
5770
  // Wait for the next message — same as after a successful turn
5107
5771
  const effectiveIdleTimeout = metadata.get(IDLE_TIMEOUT_METADATA_KEY) ??
5108
5772
  idleTimeoutInSeconds;
@@ -5162,6 +5826,8 @@ function createChatBuilder(config) {
5162
5826
  return createChatBuilder({
5163
5827
  ...config,
5164
5828
  clientDataSchema: cdConfig.schema,
5829
+ clientDataReportErrorAt: cdConfig.reportErrorAt ?? config.clientDataReportErrorAt,
5830
+ clientDataOnValidationError: cdConfig.onValidationError ?? config.clientDataOnValidationError,
5165
5831
  });
5166
5832
  },
5167
5833
  onBoot(fn) {
@@ -5242,6 +5908,10 @@ function createChatBuilder(config) {
5242
5908
  return chatCustomAgent({
5243
5909
  ...options,
5244
5910
  ...(config.clientDataSchema ? { clientDataSchema: config.clientDataSchema } : {}),
5911
+ ...(config.clientDataReportErrorAt
5912
+ ? { clientDataReportErrorAt: config.clientDataReportErrorAt }
5913
+ : {}),
5914
+ onClientDataValidationError: composeHooks(config.clientDataOnValidationError, options.onClientDataValidationError),
5245
5915
  });
5246
5916
  },
5247
5917
  };
@@ -5294,6 +5964,8 @@ function withUIMessage(config) {
5294
5964
  function withClientData(config) {
5295
5965
  return createChatBuilder({
5296
5966
  clientDataSchema: config.schema,
5967
+ clientDataReportErrorAt: config.reportErrorAt,
5968
+ clientDataOnValidationError: config.onValidationError,
5297
5969
  hooks: {},
5298
5970
  });
5299
5971
  }
@@ -5484,6 +6156,58 @@ function isStopped() {
5484
6156
  function requestUpgrade() {
5485
6157
  locals.set(chatUpgradeRequestedKey, true);
5486
6158
  }
6159
+ /**
6160
+ * Hand off the current custom agent Session to a fresh run.
6161
+ *
6162
+ * This is the low-level handoff for a fully hand-rolled
6163
+ * `chat.customAgent()` loop. This method rejects while a
6164
+ * `chat.createSession()` iterator is active. Close the iterator before calling
6165
+ * it. If `return()` races an active `next()`, it waits for that read to settle
6166
+ * before releasing the handoff guard. Call only between turns and after
6167
+ * detaching input listeners for the old run. If the old run completed its
6168
+ * current turn, persist its state and call {@link chatWriteTurnComplete} before
6169
+ * handing off.
6170
+ * Do not write a new turn boundary after input that the continuation run should
6171
+ * process has been dispatched: the boundary acknowledges that input.
6172
+ *
6173
+ * The server starts the continuation run but does not stop this run, so return
6174
+ * from the task immediately after awaiting this function. The promise rejects
6175
+ * if the server cannot complete the handoff.
6176
+ *
6177
+ * Pending Session input that the old run has not consumed remains on the
6178
+ * durable `.in` stream and is delivered to the continuation run.
6179
+ *
6180
+ * @example
6181
+ * ```ts
6182
+ * // Detach any chat.messages.on() subscriptions you created.
6183
+ * await persistMessages();
6184
+ * await chat.writeTurnComplete();
6185
+ * await chat.endAndContinue();
6186
+ * return;
6187
+ * ```
6188
+ */
6189
+ async function endAndContinue() {
6190
+ if (locals.get(chatCustomAgentRunKey) !== true) {
6191
+ throw new Error("chat.endAndContinue() can only be called from inside a chat.customAgent() run");
6192
+ }
6193
+ if ((locals.get(chatActiveSessionIteratorsKey) ?? 0) > 0) {
6194
+ throw new Error("chat.endAndContinue() cannot be called while a chat.createSession() iterator is active. Close the iterator, then call chat.endAndContinue().");
6195
+ }
6196
+ await performEndAndContinue();
6197
+ }
6198
+ /** @internal Shared server handoff used by managed and custom agent loops. */
6199
+ async function performEndAndContinue() {
6200
+ const chatId = locals.get(chatExternalIdKey);
6201
+ const callingRunId = locals.get(chatAgentRunContextKey)?.run.id;
6202
+ if (!chatId || !callingRunId) {
6203
+ throw new Error("Cannot end and continue without an active chat agent run");
6204
+ }
6205
+ const apiClient = apiClientManager.clientOrThrow();
6206
+ await apiClient.endAndContinueSession(chatId, {
6207
+ callingRunId,
6208
+ reason: "upgrade",
6209
+ });
6210
+ }
5487
6211
  /**
5488
6212
  * Exit the run after the current turn completes, without waiting for the
5489
6213
  * next message. Unlike {@link requestUpgrade}, no upgrade-required signal
@@ -5689,10 +6413,14 @@ function createStopSignal() {
5689
6413
  * task instead of round-tripping them back from the client:
5690
6414
  * - `lastEventId` — the turn-complete control record's seq_num on
5691
6415
  * `session.out`; where the next turn's output stream resumes.
5692
- * - `sessionInEventId` — the committed-consume cursor on `session.in` as of
5693
- * this turn-complete, letting a raw loop correlate the boundary with the
5694
- * exact input record it acknowledged. Trigger owns input-cursor recovery,
5695
- * so this is for correlation / out-of-sync detection, not required.
6416
+ * - `sessionInEventId` — the safe-to-resume-from cursor on `session.in` as of
6417
+ * this turn-complete. It is the highest sequence that can be resumed past
6418
+ * without skipping an unhandled message, so it is held back behind any
6419
+ * message still buffered unconsumed and is NOT necessarily the sequence of
6420
+ * the record this turn answered. Trigger owns input-cursor recovery, so this
6421
+ * is for correlation / out-of-sync detection, not required. Treat it as a
6422
+ * lower bound: a value below the record you just handled is expected, not a
6423
+ * sign of a lost turn.
5696
6424
  *
5697
6425
  * Either is `undefined` when the corresponding cursor isn't available.
5698
6426
  *
@@ -5704,15 +6432,36 @@ function createStopSignal() {
5704
6432
  * ```
5705
6433
  */
5706
6434
  async function chatWriteTurnComplete(options) {
6435
+ await flushDeferredChatCustomAgentClientDataErrors();
5707
6436
  const result = await writeTurnCompleteChunk(undefined, options?.publicAccessToken);
5708
6437
  // Same cursor written to the `session-in-event-id` header inside
5709
6438
  // `writeTurnCompleteChunk`; surfaced here so the caller can persist it.
5710
- const inCursor = getChatSession().in.lastDispatchedSeqNum();
6439
+ const inCursor = chatInputRouter().resumeFloor();
5711
6440
  return {
5712
6441
  lastEventId: result?.lastEventId,
5713
6442
  ...(inCursor !== undefined ? { sessionInEventId: String(inCursor) } : {}),
5714
6443
  };
5715
6444
  }
6445
+ /**
6446
+ * Writes the client-data errors held back by the default `"turn-end"` timing.
6447
+ *
6448
+ * Ordered before the turn-complete chunk, matching the error-then-turn-complete
6449
+ * shape the submitted-turn and async-read paths already write.
6450
+ */
6451
+ async function flushDeferredChatCustomAgentClientDataErrors() {
6452
+ const deferred = locals.get(chatCustomAgentDeferredClientDataErrorsKey);
6453
+ if (!deferred || deferred.length === 0)
6454
+ return;
6455
+ locals.set(chatCustomAgentDeferredClientDataErrorsKey, []);
6456
+ for (const payload of deferred) {
6457
+ try {
6458
+ await writeChatCustomAgentClientDataErrorToStream(payload);
6459
+ }
6460
+ catch {
6461
+ /* non-fatal */
6462
+ }
6463
+ }
6464
+ }
5716
6465
  /**
5717
6466
  * Pass every chunk through untouched while recording it in `buffer`. Handles
5718
6467
  * both the `AsyncIterable` and `ReadableStream` shapes `toUIMessageStream()`
@@ -6099,6 +6848,95 @@ class ChatMessageAccumulator {
6099
6848
  return true;
6100
6849
  }
6101
6850
  }
6851
+ function trackActiveChatSessionIterator(iterator) {
6852
+ locals.set(chatActiveSessionIteratorsKey, (locals.get(chatActiveSessionIteratorsKey) ?? 0) + 1);
6853
+ let active = true;
6854
+ let closing = false;
6855
+ let activeNextCalls = 0;
6856
+ let closePromise;
6857
+ const nextSettledWaiters = new Set();
6858
+ function finish() {
6859
+ if (!active)
6860
+ return;
6861
+ active = false;
6862
+ const remaining = Math.max((locals.get(chatActiveSessionIteratorsKey) ?? 1) - 1, 0);
6863
+ locals.set(chatActiveSessionIteratorsKey, remaining);
6864
+ }
6865
+ function settleNextCall() {
6866
+ activeNextCalls = Math.max(activeNextCalls - 1, 0);
6867
+ if (activeNextCalls > 0)
6868
+ return;
6869
+ for (const resolve of nextSettledWaiters) {
6870
+ resolve();
6871
+ }
6872
+ nextSettledWaiters.clear();
6873
+ }
6874
+ function waitForNextCalls() {
6875
+ if (activeNextCalls === 0)
6876
+ return Promise.resolve();
6877
+ return new Promise((resolve) => nextSettledWaiters.add(resolve));
6878
+ }
6879
+ function closeIterator() {
6880
+ closing = true;
6881
+ if (!closePromise) {
6882
+ closePromise = (async () => {
6883
+ // A blocked next() can install a new message listener after it resumes.
6884
+ // Let every started call settle, then make the inner cleanup final.
6885
+ await waitForNextCalls();
6886
+ try {
6887
+ return iterator.return
6888
+ ? await iterator.return()
6889
+ : { done: true, value: undefined };
6890
+ }
6891
+ finally {
6892
+ finish();
6893
+ }
6894
+ })();
6895
+ }
6896
+ return closePromise;
6897
+ }
6898
+ return {
6899
+ async next() {
6900
+ if (closing) {
6901
+ return { done: true, value: undefined };
6902
+ }
6903
+ activeNextCalls++;
6904
+ let result;
6905
+ try {
6906
+ result = await iterator.next();
6907
+ }
6908
+ catch (error) {
6909
+ settleNextCall();
6910
+ try {
6911
+ await closeIterator();
6912
+ }
6913
+ catch {
6914
+ // Preserve the original iterator error after best-effort cleanup.
6915
+ }
6916
+ throw error;
6917
+ }
6918
+ settleNextCall();
6919
+ if (result.done) {
6920
+ try {
6921
+ await closeIterator();
6922
+ }
6923
+ catch {
6924
+ // The inner next() already ended cleanly; cleanup remains best-effort.
6925
+ }
6926
+ }
6927
+ else if (closing) {
6928
+ // return() won the race. Do not expose a turn the caller has already
6929
+ // abandoned; without a new turn-complete boundary its input remains
6930
+ // replayable by the continuation run.
6931
+ return { done: true, value: undefined };
6932
+ }
6933
+ return result;
6934
+ },
6935
+ return() {
6936
+ return closeIterator();
6937
+ },
6938
+ };
6939
+ }
6102
6940
  /**
6103
6941
  * Create a chat session that yields turns as an async iterator.
6104
6942
  *
@@ -6150,20 +6988,18 @@ function createChatSession(payload, options) {
6150
6988
  const accumulator = new ChatMessageAccumulator();
6151
6989
  let previousTurnUsage;
6152
6990
  let cumulativeUsage = emptyUsage();
6153
- // Messages consumed mid-turn, dispatched one per next(). Iterator-level
6154
- // for the same reason as the agent loop's `pendingWireMessages`:
6155
- // consumed records never replay, so a turn-local buffer loses them.
6156
- const sessionPendingWire = [];
6157
6991
  // The current turn's message subscription — detached defensively at the
6158
6992
  // top of next() in case user code threw without complete()/done().
6159
6993
  let activeMsgSub;
6160
- return {
6994
+ const iterator = {
6161
6995
  async next() {
6162
6996
  activeMsgSub?.off();
6163
6997
  activeMsgSub = undefined;
6164
6998
  if (!booted) {
6165
6999
  booted = true;
6166
- await seedSessionInResumeCursorForCustomLoop(currentPayload);
7000
+ await installChatInputRouter(currentPayload.chatId, {
7001
+ resuming: Boolean(currentPayload.continuation),
7002
+ });
6167
7003
  stop = createStopSignal();
6168
7004
  }
6169
7005
  turn++;
@@ -6220,29 +7056,32 @@ function createChatSession(payload, options) {
6220
7056
  currentPayload = { ...currentPayload, continuation: true };
6221
7057
  }
6222
7058
  }
6223
- // Subsequent turns: drain buffered mid-turn messages first (they
6224
- // were consumed and won't be re-delivered), then wait.
7059
+ /**
7060
+ * Subsequent turns take the next message from the router. A record
7061
+ * that arrived mid-turn is already queued there, so this returns it
7062
+ * without suspending.
7063
+ */
6225
7064
  if (turn > 0) {
6226
- if (sessionPendingWire.length > 0) {
6227
- currentPayload = sessionPendingWire.shift();
7065
+ // chat.requestUpgrade() / chat.endRun() — exit before waiting
7066
+ if (locals.get(chatUpgradeRequestedKey) || locals.get(chatEndRunRequestedKey)) {
7067
+ stop.cleanup();
7068
+ return { done: true, value: undefined };
6228
7069
  }
6229
- else {
6230
- // chat.requestUpgrade() / chat.endRun() — exit before waiting
6231
- if (locals.get(chatUpgradeRequestedKey) || locals.get(chatEndRunRequestedKey)) {
6232
- stop.cleanup();
6233
- return { done: true, value: undefined };
6234
- }
6235
- const next = await messagesInput.waitWithIdleTimeout({
6236
- idleTimeoutInSeconds,
6237
- timeout,
6238
- spanName: "waiting for next message",
6239
- });
6240
- if (!next.ok || runSignal.aborted) {
6241
- stop.cleanup();
6242
- return { done: true, value: undefined };
6243
- }
6244
- currentPayload = next.output;
7070
+ const next = await messagesInput.waitWithIdleTimeout({
7071
+ idleTimeoutInSeconds,
7072
+ timeout,
7073
+ spanName: "waiting for next message",
7074
+ });
7075
+ if (!next.ok || runSignal.aborted) {
7076
+ stop.cleanup();
7077
+ return { done: true, value: undefined };
6245
7078
  }
7079
+ /**
7080
+ * The facade validated `clientData` against the configured schema
7081
+ * before returning, so the parsed shape is what arrives here. The
7082
+ * cast carries that across the untyped wire payload boundary.
7083
+ */
7084
+ currentPayload = next.output;
6246
7085
  }
6247
7086
  // Check limits
6248
7087
  if (turn >= maxTurns || runSignal.aborted) {
@@ -6266,39 +7105,49 @@ function createChatSession(payload, options) {
6266
7105
  continuation: currentPayload.continuation ?? false,
6267
7106
  clientData: currentPayload.metadata,
6268
7107
  });
6269
- // Listen for messages during streaming (steering + next-turn buffer)
6270
- const sessionMsgSub = messagesInput.on(async (msg) => {
6271
- if (sessionPendingMessages) {
6272
- // Steering route the frontend re-sends non-injected
6273
- // messages on turn complete, so don't also buffer the wire.
6274
- // Slim wire: at most one delta message per record. Read
6275
- // `msg.message` directly — no array slicing needed.
6276
- const lastUIMessage = msg.message;
6277
- if (lastUIMessage) {
6278
- if (sessionPendingMessages.onReceived) {
6279
- try {
6280
- await sessionPendingMessages.onReceived({
6281
- message: lastUIMessage,
6282
- chatId: currentPayload.chatId,
6283
- turn,
6284
- });
6285
- }
6286
- catch {
6287
- /* non-fatal */
6288
- }
6289
- }
7108
+ /**
7109
+ * Only attached when there is a steering config to feed. Without one a
7110
+ * mid-turn message stays queued on the router, which is what keeps the
7111
+ * resume floor behind it.
7112
+ */
7113
+ const handleSteeringMessage = async (msg, seqNum, isActive = () => true) => {
7114
+ const lastUIMessage = msg.message;
7115
+ if (lastUIMessage) {
7116
+ if (sessionPendingMessages?.onReceived) {
6290
7117
  try {
6291
- const modelMsgs = await toModelMessages([lastUIMessage]);
6292
- turnSteeringQueue.push({ uiMessage: lastUIMessage, modelMessages: modelMsgs });
7118
+ await sessionPendingMessages.onReceived({
7119
+ message: lastUIMessage,
7120
+ chatId: currentPayload.chatId,
7121
+ turn,
7122
+ });
6293
7123
  }
6294
7124
  catch {
6295
7125
  /* non-fatal */
6296
7126
  }
6297
7127
  }
6298
- return;
7128
+ if (!isActive())
7129
+ return;
7130
+ try {
7131
+ const modelMsgs = await toModelMessages([lastUIMessage]);
7132
+ turnSteeringQueue.push({
7133
+ uiMessage: lastUIMessage,
7134
+ modelMessages: modelMsgs,
7135
+ seqNum,
7136
+ });
7137
+ }
7138
+ catch {
7139
+ /* non-fatal */
7140
+ }
6299
7141
  }
6300
- sessionPendingWire.push(msg);
6301
- });
7142
+ };
7143
+ const sessionMsgSub = sessionPendingMessages
7144
+ ? locals.get(chatCustomAgentClientDataParserKey)
7145
+ ? observeValidatedChatMessages((msg, seqNum, isActive) => handleSteeringMessage(msg, seqNum, isActive))
7146
+ : chatInputRouter().observe(CHAT_ROUTE_MESSAGES, (record) => {
7147
+ const msg = record.data.payload;
7148
+ void handleSteeringMessage(msg, record.seqNum);
7149
+ })
7150
+ : undefined;
6302
7151
  activeMsgSub = sessionMsgSub;
6303
7152
  // Accumulate messages. Slim wire: pass the single delta message as
6304
7153
  // a 0-or-1-length array. The accumulator's behavior is unchanged —
@@ -6320,7 +7169,7 @@ function createChatSession(payload, options) {
6320
7169
  // chat.requestUpgrade() called before this turn — signal transport and exit
6321
7170
  if (locals.get(chatUpgradeRequestedKey)) {
6322
7171
  await writeUpgradeRequiredChunk();
6323
- sessionMsgSub.off();
7172
+ sessionMsgSub?.off();
6324
7173
  stop.cleanup();
6325
7174
  return { done: true, value: undefined };
6326
7175
  }
@@ -6360,7 +7209,7 @@ function createChatSession(payload, options) {
6360
7209
  if (!response || response.role !== "assistant") {
6361
7210
  throw new Error("turn.complete() could not find the spliced handover response");
6362
7211
  }
6363
- sessionMsgSub.off();
7212
+ sessionMsgSub?.off();
6364
7213
  await chatWriteTurnComplete();
6365
7214
  return response;
6366
7215
  }
@@ -6377,7 +7226,7 @@ function createChatSession(payload, options) {
6377
7226
  });
6378
7227
  if (runSignal.aborted) {
6379
7228
  // Full cancel — don't accumulate
6380
- sessionMsgSub.off();
7229
+ sessionMsgSub?.off();
6381
7230
  await chatWriteTurnComplete();
6382
7231
  return undefined;
6383
7232
  }
@@ -6400,7 +7249,7 @@ function createChatSession(payload, options) {
6400
7249
  finally {
6401
7250
  // Detach at stream end (like the agent loop): the steering queue
6402
7251
  // can't inject anymore, so later arrivals must buffer for the next turn.
6403
- sessionMsgSub.off();
7252
+ sessionMsgSub?.off();
6404
7253
  }
6405
7254
  if (response) {
6406
7255
  const cleaned = stop.signal.aborted && !runSignal.aborted
@@ -6496,7 +7345,7 @@ function createChatSession(payload, options) {
6496
7345
  }
6497
7346
  }
6498
7347
  }
6499
- sessionMsgSub.off();
7348
+ sessionMsgSub?.off();
6500
7349
  await chatWriteTurnComplete();
6501
7350
  return response;
6502
7351
  },
@@ -6513,7 +7362,7 @@ function createChatSession(payload, options) {
6513
7362
  await accumulator.addResponse(response);
6514
7363
  },
6515
7364
  async done() {
6516
- sessionMsgSub.off();
7365
+ sessionMsgSub?.off();
6517
7366
  await chatWriteTurnComplete();
6518
7367
  },
6519
7368
  prepareStep() {
@@ -6552,6 +7401,7 @@ function createChatSession(payload, options) {
6552
7401
  return { done: true, value: undefined };
6553
7402
  },
6554
7403
  };
7404
+ return trackActiveChatSessionIterator(iterator);
6555
7405
  },
6556
7406
  };
6557
7407
  }
@@ -7000,6 +7850,8 @@ export const chat = {
7000
7850
  isStopped,
7001
7851
  /** Request that the run exits after the current turn so the next message starts on the latest version. See {@link requestUpgrade}. */
7002
7852
  requestUpgrade,
7853
+ /** Hand off a custom agent Session to a fresh run. See {@link endAndContinue}. */
7854
+ endAndContinue,
7003
7855
  /** Exit the run after the current turn completes, without any upgrade signal. See {@link endRun}. */
7004
7856
  endRun,
7005
7857
  /** Clean up aborted parts from a UIMessage. See {@link cleanupAbortedParts}. */
@@ -7107,6 +7959,16 @@ export const chat = {
7107
7959
  */
7108
7960
  async function writeTurnCompleteChunk(_chatId, publicAccessToken) {
7109
7961
  const session = getChatSession();
7962
+ // A handover-prepare boot claims the handover kinds so a signal arriving
7963
+ // before `waitForHandover` attaches is not drained. Released here rather than
7964
+ // only in `waitForHandover`, because a loop that never calls it would
7965
+ // otherwise hold the claim for the life of the run and leave a handover
7966
+ // record parked at the head of the channel, where it wedges
7967
+ // `chat.messages.next()`. Every surface reaches a turn boundary through this
7968
+ // function, including the managed agent, which does not call the public
7969
+ // `chat.writeTurnComplete`. By the time a turn completes the handover window
7970
+ // is over either way.
7971
+ chatInputRouter().clearRoute(CHAT_ROUTE_HANDOVER);
7110
7972
  // 1. Write the turn-complete control record. The ack's `lastEventId` is
7111
7973
  // this record's seq_num — that's the trim target for the NEXT turn.
7112
7974
  //
@@ -7121,10 +7983,15 @@ async function writeTurnCompleteChunk(_chatId, publicAccessToken) {
7121
7983
  if (publicAccessToken) {
7122
7984
  extraHeaders.push(["public-access-token", publicAccessToken]);
7123
7985
  }
7124
- const inCursor = session.in.lastDispatchedSeqNum();
7986
+ const routerCheckpoint = chatInputRouter().checkpoint();
7987
+ const inCursor = routerCheckpoint.resumeFrom;
7125
7988
  if (inCursor !== undefined) {
7126
7989
  extraHeaders.push([SESSION_IN_EVENT_ID_HEADER, String(inCursor)]);
7127
7990
  }
7991
+ const consumedCursor = routerCheckpoint.appliedThrough;
7992
+ if (consumedCursor !== undefined) {
7993
+ extraHeaders.push([SESSION_IN_CONSUMED_ID_HEADER, String(consumedCursor)]);
7994
+ }
7128
7995
  const result = await session.out.writeControl(TRIGGER_CONTROL_SUBTYPE.TURN_COMPLETE, extraHeaders);
7129
7996
  const T_N = result.lastEventId ? Number.parseInt(result.lastEventId, 10) : undefined;
7130
7997
  // 2. Trim back to the previous turn-complete, if we have one. Skipping on
@@ -7183,16 +8050,11 @@ async function writeTurnCompleteChunk(_chatId, publicAccessToken) {
7183
8050
  * @internal
7184
8051
  */
7185
8052
  async function writeUpgradeRequiredChunk() {
7186
- const ctx = taskContext.ctx;
7187
- const chatId = ctx?.run.id ? getChatIdFromContext() : undefined;
7188
- const callingRunId = ctx?.run.id;
8053
+ const chatId = locals.get(chatExternalIdKey);
8054
+ const callingRunId = locals.get(chatAgentRunContextKey)?.run.id;
7189
8055
  if (chatId && callingRunId) {
7190
- const apiClient = apiClientManager.clientOrThrow();
7191
8056
  try {
7192
- await apiClient.endAndContinueSession(chatId, {
7193
- callingRunId,
7194
- reason: "upgrade",
7195
- });
8057
+ await performEndAndContinue();
7196
8058
  }
7197
8059
  catch (error) {
7198
8060
  // Non-fatal: the next `.in/append` re-triggers via the probe.
@@ -7207,16 +8069,6 @@ async function writeUpgradeRequiredChunk() {
7207
8069
  const session = getChatSession();
7208
8070
  return session.out.writeControl(TRIGGER_CONTROL_SUBTYPE.UPGRADE_REQUIRED);
7209
8071
  }
7210
- /**
7211
- * Resolves the current chat's `chatId` (used as session externalId) from
7212
- * the bound session handle. Returns `undefined` if no agent is bound —
7213
- * shouldn't happen at the call sites that invoke
7214
- * `writeUpgradeRequiredChunk`, but defensive against misuse.
7215
- * @internal
7216
- */
7217
- function getChatIdFromContext() {
7218
- return locals.get(chatSessionHandleKey)?.id;
7219
- }
7220
8072
  /**
7221
8073
  * Extracts the text content of the last user message from a UIMessage array.
7222
8074
  * Returns undefined if no user message is found.