@trigger.dev/sdk 4.5.12 → 4.5.13

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 (50) 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/test/mock-chat-agent.js +1 -0
  14. package/dist/commonjs/v3/test/mock-chat-agent.js.map +1 -1
  15. package/dist/commonjs/v3/test/test-session-handle.js +22 -23
  16. package/dist/commonjs/v3/test/test-session-handle.js.map +1 -1
  17. package/dist/commonjs/version.js +1 -1
  18. package/dist/esm/v3/ai.d.ts +154 -20
  19. package/dist/esm/v3/ai.js +1239 -387
  20. package/dist/esm/v3/ai.js.map +1 -1
  21. package/dist/esm/v3/chat.d.ts +7 -2
  22. package/dist/esm/v3/chat.js +22 -7
  23. package/dist/esm/v3/chat.js.map +1 -1
  24. package/dist/esm/v3/chat.test.js +13 -4
  25. package/dist/esm/v3/chat.test.js.map +1 -1
  26. package/dist/esm/v3/envvars.js.map +1 -1
  27. package/dist/esm/v3/sessions.d.ts +4 -10
  28. package/dist/esm/v3/sessions.js +73 -47
  29. package/dist/esm/v3/sessions.js.map +1 -1
  30. package/dist/esm/v3/test/mock-chat-agent.js +2 -1
  31. package/dist/esm/v3/test/mock-chat-agent.js.map +1 -1
  32. package/dist/esm/v3/test/test-session-handle.js +23 -24
  33. package/dist/esm/v3/test/test-session-handle.js.map +1 -1
  34. package/dist/esm/version.js +1 -1
  35. package/docs/ai-chat/client-protocol.mdx +8 -3
  36. package/docs/ai-chat/custom-agents.mdx +181 -46
  37. package/docs/ai-chat/patterns/recovery-boot.mdx +9 -2
  38. package/docs/ai-chat/patterns/version-upgrades.mdx +26 -6
  39. package/docs/ai-chat/pending-messages.mdx +5 -3
  40. package/docs/ai-chat/reference.mdx +26 -10
  41. package/docs/ai-chat/types.mdx +5 -1
  42. package/docs/deployment/atomic-deployment.mdx +12 -0
  43. package/docs/deployment/overview.mdx +7 -1
  44. package/docs/deployment/version-skew-protection.mdx +430 -0
  45. package/docs/github-actions.mdx +33 -5
  46. package/docs/github-integration.mdx +12 -0
  47. package/docs/self-hosting/env/webapp.mdx +1 -0
  48. package/docs/vercel-integration.mdx +43 -9
  49. package/docs/versioning.mdx +2 -0
  50. package/package.json +2 -2
@@ -1,7 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.chat = exports.upsertIncomingMessage = exports.PENDING_MESSAGE_INJECTED_TYPE = exports.ai = void 0;
4
- exports.__findLatestSessionInCursorForTests = __findLatestSessionInCursorForTests;
5
4
  exports.__setReadChatSnapshotImplForTests = __setReadChatSnapshotImplForTests;
6
5
  exports.__setWriteChatSnapshotImplForTests = __setWriteChatSnapshotImplForTests;
7
6
  exports.__readChatSnapshotProductionPathForTests = __readChatSnapshotProductionPathForTests;
@@ -11,6 +10,9 @@ exports.__setReplaySessionOutTailImplForTests = __setReplaySessionOutTailImplFor
11
10
  exports.__replaySessionOutTailProductionPathForTests = __replaySessionOutTailProductionPathForTests;
12
11
  exports.__setReplaySessionInTailImplForTests = __setReplaySessionInTailImplForTests;
13
12
  exports.__replaySessionInTailProductionPathForTests = __replaySessionInTailProductionPathForTests;
13
+ exports.__resetChatInputRouterForTests = __resetChatInputRouterForTests;
14
+ exports.__chatInputCheckpointForTests = __chatInputCheckpointForTests;
15
+ exports.__findLatestSessionInCheckpointForTests = __findLatestSessionInCheckpointForTests;
14
16
  exports.buildSkillTools = buildSkillTools;
15
17
  const v3_1 = require("@trigger.dev/core/v3");
16
18
  // Runtime VALUES go through the ESM/CJS shim so the CJS build can `require`
@@ -119,49 +121,6 @@ async function findLatestSessionInCursor(chatId) {
119
121
  }
120
122
  return latestCursor;
121
123
  }
122
- /** Test-only entry point for the records-based cursor scan. @internal */
123
- async function __findLatestSessionInCursorForTests(chatId) {
124
- return findLatestSessionInCursor(chatId);
125
- }
126
- /**
127
- * Seed the `.in` resume cursor for custom-agent loops (`chat.customAgent`
128
- * raw loops and `chat.createSession`) the way `chat.agent`'s boot does.
129
- *
130
- * MUST run before anything attaches a `.in` listener (`createStopSignal`,
131
- * `chat.messages.on`, the first wait): attaching opens the SSE tail with
132
- * `Last-Event-ID` from the seeded cursor, so attach-then-seed replays
133
- * every record from seq 0 — already-answered user messages get delivered
134
- * into the new run's first wait and the loop re-answers them.
135
- *
136
- * Seeds both cursors: `setLastSeqNum` controls the SSE `Last-Event-ID`,
137
- * `setLastDispatchedSeqNum` gates waiter dispatch — seeding only the
138
- * former still re-delivers records the manager buffered before the seed.
139
- *
140
- * No-ops on fresh boots and when a cursor is already seeded (e.g. the
141
- * `chatCustomAgent` wrapper ran before a nested `createChatSession`).
142
- * @internal
143
- */
144
- async function seedSessionInResumeCursorForCustomLoop(payload) {
145
- if (v3_1.sessionStreams.lastSeqNum(payload.chatId, "in") !== undefined)
146
- return;
147
- // No continuation/attempt gate: the wire may omit `continuation` on a
148
- // run that still has prior turns (chat.agent covers that case via its
149
- // snapshot). The scan doubles as the prior-state probe — a fresh
150
- // session has no turn-complete on `.out`, returns no cursor, and
151
- // seeds nothing. Cost on fresh boots is one non-blocking records read.
152
- try {
153
- const cursor = await findLatestSessionInCursor(payload.chatId);
154
- if (cursor !== undefined) {
155
- v3_1.sessionStreams.setLastSeqNum(payload.chatId, "in", cursor);
156
- v3_1.sessionStreams.setLastDispatchedSeqNum(payload.chatId, "in", cursor);
157
- }
158
- }
159
- catch (error) {
160
- v3_1.logger.warn("chat session: session.in resume cursor lookup failed; old messages may replay", {
161
- error: error instanceof Error ? error.message : String(error),
162
- });
163
- }
164
- }
165
124
  let readChatSnapshotImpl;
166
125
  function __setReadChatSnapshotImplForTests(impl) {
167
126
  readChatSnapshotImpl = impl;
@@ -1025,90 +984,508 @@ async function withChatWriter(fn) {
1025
984
  await flush();
1026
985
  return result;
1027
986
  }
1028
- // Input streams for bidirectional chat communication
1029
- //
1030
- // Both `messagesInput` and `stopInput` are thin facades over the current
1031
- // run's Session `.in` channel. The Session carries a single tagged stream
1032
- // (`ChatInputChunk`); these facades filter by `kind` so existing call
1033
- // sites (both internal and exposed via `chat.messages` / `chat.createStopSignal`)
1034
- // keep their original shape. Each accessor resolves the session handle
1035
- // lazily via `getChatSession()` so the module-level references stay
1036
- // compatible with the pre-migration wiring.
987
+ const CHAT_CUSTOM_AGENT_CLIENT_DATA_ERROR_TEXT = "Invalid client data";
988
+ const chatCustomAgentClientDataParserKey = locals_js_1.locals.create("chat.customAgentClientDataParser");
989
+ const chatCustomAgentClientDataErrorHandlerKey = locals_js_1.locals.create("chat.customAgentClientDataErrorHandler");
990
+ function shouldValidateChatCustomAgentPayload(payload) {
991
+ return (payload.trigger !== "close" && locals_js_1.locals.get(chatCustomAgentClientDataParserKey) !== undefined);
992
+ }
993
+ function assertChatCustomAgentSyncParseResult(result) {
994
+ if (result && typeof result.then === "function") {
995
+ void Promise.resolve(result).catch(() => { });
996
+ throw new Error("chat.messages.peek() cannot validate clientData with an asynchronous schema. " +
997
+ "Use chat.messages.once(), chat.messages.wait(), or chat.messages.waitWithIdleTimeout().");
998
+ }
999
+ return result;
1000
+ }
1001
+ function getChatCustomAgentSyncSchemaParseFn(schema) {
1002
+ const parser = schema;
1003
+ if (typeof parser === "function" && typeof parser.assert === "function") {
1004
+ return parser.assert.bind(parser);
1005
+ }
1006
+ if (typeof parser === "function") {
1007
+ return (value) => assertChatCustomAgentSyncParseResult(parser(value));
1008
+ }
1009
+ if (typeof parser.parse === "function") {
1010
+ return (value) => assertChatCustomAgentSyncParseResult(parser.parse(value));
1011
+ }
1012
+ if (typeof parser.validateSync === "function") {
1013
+ return parser.validateSync.bind(parser);
1014
+ }
1015
+ if (typeof parser.create === "function") {
1016
+ return parser.create.bind(parser);
1017
+ }
1018
+ if (typeof parser.assert === "function") {
1019
+ return (value) => {
1020
+ parser.assert(value);
1021
+ return value;
1022
+ };
1023
+ }
1024
+ return () => {
1025
+ throw new Error("chat.messages.peek() cannot validate clientData with this schema. " +
1026
+ "Use chat.messages.once(), chat.messages.wait(), or chat.messages.waitWithIdleTimeout().");
1027
+ };
1028
+ }
1029
+ async function writeChatCustomAgentClientDataErrorToStream(payload) {
1030
+ try {
1031
+ await withChatWriter((writer) => {
1032
+ writer.write({
1033
+ type: "error",
1034
+ errorText: CHAT_CUSTOM_AGENT_CLIENT_DATA_ERROR_TEXT,
1035
+ });
1036
+ });
1037
+ await chatWriteTurnComplete();
1038
+ }
1039
+ catch (signalError) {
1040
+ v3_1.logger.warn("chat.customAgent: failed to report clientData validation error", {
1041
+ chatId: payload.chatId,
1042
+ trigger: payload.trigger,
1043
+ error: signalError instanceof Error ? signalError.message : String(signalError),
1044
+ });
1045
+ }
1046
+ }
1047
+ async function reportChatCustomAgentClientDataError(payload, error, options) {
1048
+ const errorText = error instanceof Error ? error.message : "An unexpected error occurred";
1049
+ v3_1.logger.warn("chat.customAgent: clientData validation failed", {
1050
+ chatId: payload.chatId,
1051
+ trigger: payload.trigger,
1052
+ error: errorText,
1053
+ });
1054
+ const errorHandler = options.callHandler === false
1055
+ ? undefined
1056
+ : locals_js_1.locals.get(chatCustomAgentClientDataErrorHandlerKey);
1057
+ if (errorHandler) {
1058
+ try {
1059
+ await errorHandler({ error, payload });
1060
+ }
1061
+ catch (handlerError) {
1062
+ v3_1.logger.warn("chat.customAgent: clientData validation error handler failed", {
1063
+ chatId: payload.chatId,
1064
+ trigger: payload.trigger,
1065
+ error: handlerError instanceof Error ? handlerError.message : String(handlerError),
1066
+ });
1067
+ }
1068
+ }
1069
+ if (!options.writeToStream) {
1070
+ return;
1071
+ }
1072
+ await writeChatCustomAgentClientDataErrorToStream(payload);
1073
+ }
1074
+ async function parseChatCustomAgentPayload(payload) {
1075
+ const parser = locals_js_1.locals.get(chatCustomAgentClientDataParserKey);
1076
+ if (!parser || payload.trigger === "close") {
1077
+ return { ok: true, payload };
1078
+ }
1079
+ try {
1080
+ const metadata = await parser.parse(payload.metadata);
1081
+ return { ok: true, payload: { ...payload, metadata } };
1082
+ }
1083
+ catch (error) {
1084
+ return { ok: false, error };
1085
+ }
1086
+ }
1087
+ async function validateChatCustomAgentPayload(payload, options = {}) {
1088
+ const result = await parseChatCustomAgentPayload(payload);
1089
+ if (!result.ok) {
1090
+ await reportChatCustomAgentClientDataError(payload, result.error, {
1091
+ writeToStream: options.writeErrorToStream ?? true,
1092
+ });
1093
+ }
1094
+ return result;
1095
+ }
1096
+ function validateChatCustomAgentPayloadSync(payload) {
1097
+ const parser = locals_js_1.locals.get(chatCustomAgentClientDataParserKey);
1098
+ if (!parser || payload.trigger === "close") {
1099
+ return payload;
1100
+ }
1101
+ try {
1102
+ return { ...payload, metadata: parser.parseSync(payload.metadata) };
1103
+ }
1104
+ catch (error) {
1105
+ v3_1.logger.warn("chat.customAgent: clientData validation failed in chat.messages.peek()", {
1106
+ chatId: payload.chatId,
1107
+ trigger: payload.trigger,
1108
+ error: error instanceof Error ? error.message : String(error),
1109
+ });
1110
+ throw error;
1111
+ }
1112
+ }
1113
+ /**
1114
+ * Read one record from a route, suspending the run if nothing is there yet.
1115
+ *
1116
+ * The wake and the read are separate steps: the channel wakes the run, then the
1117
+ * router hands over whatever it routed. Nothing else can take the record in
1118
+ * between, which is what keeps the published cursors and the delivered record
1119
+ * in agreement.
1120
+ * @internal
1121
+ */
1122
+ async function waitOnChatRoute(route, options) {
1123
+ const router = chatInputRouter();
1124
+ const session = getChatSession();
1125
+ return tracer_js_1.tracer.startActiveSpan(options.spanName ?? `chat.${route}.wait()`, async (span) => {
1126
+ const idleMs = (options.idleTimeoutInSeconds ?? 0) * 1000;
1127
+ if (idleMs > 0) {
1128
+ const warm = await router.next(route, { timeoutMs: idleMs });
1129
+ if (warm) {
1130
+ span.setAttribute("wait.resolved", "idle");
1131
+ return { ok: true, output: warm.data, record: warm };
1132
+ }
1133
+ }
1134
+ else {
1135
+ const buffered = await router.next(route, { timeoutMs: 0 });
1136
+ if (buffered) {
1137
+ span.setAttribute("wait.resolved", "buffered");
1138
+ return { ok: true, output: buffered.data, record: buffered };
1139
+ }
1140
+ }
1141
+ if (options.skipSuspend) {
1142
+ span.setAttribute("wait.resolved", "skipped");
1143
+ return {
1144
+ ok: false,
1145
+ error: new Error("Idle timeout elapsed and skipSuspend is set"),
1146
+ };
1147
+ }
1148
+ if (options.onSuspend)
1149
+ await options.onSuspend();
1150
+ span.setAttribute("wait.resolved", "suspended");
1151
+ while (true) {
1152
+ /**
1153
+ * The floor doubles as the wake cursor: the server completes the
1154
+ * waitpoint immediately if anything sits after this sequence, so a
1155
+ * floor that has advanced past an unread record parks a waitpoint
1156
+ * nothing will complete. Recorded on the span so a run that never woke
1157
+ * can be diagnosed from its trace alone.
1158
+ */
1159
+ const wakeFrom = router.resumeFloor();
1160
+ span.setAttribute("wait.lastSeqNum", wakeFrom ?? -1);
1161
+ const wake = await session.in.awaitWake({
1162
+ timeout: options.timeout,
1163
+ lastSeqNum: wakeFrom,
1164
+ });
1165
+ if (!wake.ok) {
1166
+ span.recordException(wake.error);
1167
+ return { ok: false, error: wake.error };
1168
+ }
1169
+ const record = await router.next(route);
1170
+ if (!record)
1171
+ continue;
1172
+ if (options.onResume)
1173
+ await options.onResume();
1174
+ return { ok: true, output: record.data, record };
1175
+ }
1176
+ }, {
1177
+ attributes: {
1178
+ [v3_1.SemanticInternalAttributes.STYLE_ICON]: "sessions",
1179
+ session: session.id,
1180
+ io: "in",
1181
+ route,
1182
+ ...(0, v3_1.accessoryAttributes)({
1183
+ items: [{ text: `${session.id}.in:${route}`, variant: "normal" }],
1184
+ style: "codepath",
1185
+ }),
1186
+ },
1187
+ });
1188
+ }
1189
+ /**
1190
+ * Raw `.in` message delivery, with no client-data validation.
1191
+ *
1192
+ * Sits directly on the router's `messages` route, so a record handed to the
1193
+ * handler is consumed and the resume floor is free to advance past it.
1194
+ */
1195
+ function subscribeToRawChatMessages(handler) {
1196
+ return chatInputRouter().on(CHAT_ROUTE_MESSAGES, (record) => {
1197
+ const chunk = record.data;
1198
+ void Promise.resolve(handler(chunk.payload)).catch(() => { });
1199
+ });
1200
+ }
1201
+ /**
1202
+ * Message delivery with client-data validation in front of it.
1203
+ *
1204
+ * Parses are chained so payloads are validated in wire order, which is the
1205
+ * point of the feature: a later message must not be validated against state a
1206
+ * earlier one has not established yet.
1207
+ *
1208
+ * The handler is dispatched but **not** awaited by that chain. Awaiting it
1209
+ * would serialise user code, so one slow or never-resolving handler would stall
1210
+ * delivery of every later message, and only when a schema is declared. Raw
1211
+ * delivery has always been fire-and-forget, so awaiting here would also make
1212
+ * handler concurrency differ between the validated and unvalidated paths for no
1213
+ * stated reason.
1214
+ *
1215
+ * `off()` detaches from the router but does not cancel work already chained:
1216
+ * the parse chain is a live promise chain and runs to completion on its own, so
1217
+ * a frame that arrives just before a turn closes is still parsed and a failure
1218
+ * is still reported (through the `!active` branch). Nothing therefore has to
1219
+ * wait on it, which is why no `drain()` hook is exposed. The one uncovered edge
1220
+ * is a parse still in flight when the task itself returns, where teardown can
1221
+ * cut the report short; give this a bounded wait at the run-end boundary rather
1222
+ * than an unbounded one, since the chain awaits a user-supplied schema.
1223
+ */
1224
+ function subscribeToValidatedChatMessages(handler, options = {}) {
1225
+ let active = true;
1226
+ let delivery = Promise.resolve();
1227
+ const subscription = subscribeToRawChatMessages((payload) => {
1228
+ delivery = delivery
1229
+ .then(async () => {
1230
+ const result = await parseChatCustomAgentPayload(payload);
1231
+ if (!result.ok) {
1232
+ if (active) {
1233
+ // Completing the turn here could close an active response.
1234
+ await reportChatCustomAgentClientDataError(payload, result.error, {
1235
+ writeToStream: false,
1236
+ });
1237
+ }
1238
+ else if (options.onInvalidAfterOff) {
1239
+ await options.onInvalidAfterOff(payload, result.error);
1240
+ }
1241
+ else {
1242
+ // The subscription was removed while parsing. Keep the failure
1243
+ // observable without invoking a user callback after off().
1244
+ await reportChatCustomAgentClientDataError(payload, result.error, {
1245
+ writeToStream: false,
1246
+ callHandler: false,
1247
+ });
1248
+ }
1249
+ return;
1250
+ }
1251
+ if (active) {
1252
+ void Promise.resolve(handler(result.payload, () => active)).catch(() => { });
1253
+ }
1254
+ else {
1255
+ void Promise.resolve(options.onAfterOff?.(result.payload)).catch(() => { });
1256
+ }
1257
+ })
1258
+ .catch(() => { });
1259
+ });
1260
+ return {
1261
+ off() {
1262
+ active = false;
1263
+ subscription.off();
1264
+ },
1265
+ };
1266
+ }
1267
+ /**
1268
+ * Validated delivery that does **not** consume.
1269
+ *
1270
+ * The steering path must observe rather than take: a record it does not inject
1271
+ * has to stay queued so a later turn answers it. Validation still runs in wire
1272
+ * order in front of the handler, so `clientData` is parsed before steering code
1273
+ * sees it, and a frame whose parse finishes after the turn closed needs no
1274
+ * buffer of its own, because the record was never removed from the channel.
1275
+ */
1276
+ function observeValidatedChatMessages(
1277
+ /**
1278
+ * Receives the observed record's own `seqNum`. It is passed per record rather
1279
+ * than tracked outside, because the handler runs after an await: with two
1280
+ * frames in flight a shared slot already holds the newer sequence by the time
1281
+ * the older frame's parse resolves, and the steering queue would then take
1282
+ * the wrong record off the channel.
1283
+ */
1284
+ handler) {
1285
+ let active = true;
1286
+ let delivery = Promise.resolve();
1287
+ const claims = locals_js_1.locals.get(chatObserverClaimedSeqNumsKey) ??
1288
+ new Map();
1289
+ locals_js_1.locals.set(chatObserverClaimedSeqNumsKey, claims);
1290
+ const subscription = chatInputRouter().observe(CHAT_ROUTE_MESSAGES, (record) => {
1291
+ const payload = record.data.payload;
1292
+ const seqNum = record.seqNum;
1293
+ /**
1294
+ * Claimed synchronously, before any await, so a read cannot validate the
1295
+ * same record. The promise lets a read wait for the outcome rather than
1296
+ * spin on a record it is not allowed to take.
1297
+ */
1298
+ let release;
1299
+ const released = new Promise((resolve) => {
1300
+ release = resolve;
1301
+ });
1302
+ claims.set(record.seqNum, { released, release });
1303
+ const releaseClaim = () => {
1304
+ claims.delete(record.seqNum);
1305
+ release();
1306
+ };
1307
+ delivery = delivery
1308
+ .then(async () => {
1309
+ const result = await parseChatCustomAgentPayload(payload);
1310
+ if (!result.ok) {
1311
+ /**
1312
+ * Reported once, then taken off the channel.
1313
+ *
1314
+ * Observing does not consume, so without this the record would be
1315
+ * read again by the next turn and parsed a second time, firing a
1316
+ * user-supplied schema and reporting the same failure twice. A frame
1317
+ * that fails validation is never going to be answered by this run or
1318
+ * a later one, so removing it loses nothing that was still owed.
1319
+ */
1320
+ const timing = locals_js_1.locals.get(chatCustomAgentClientDataErrorTimingKey) ?? "turn-end";
1321
+ const writeNow = timing === "arrival" || !active;
1322
+ await reportChatCustomAgentClientDataError(payload, result.error, {
1323
+ writeToStream: writeNow,
1324
+ });
1325
+ if (!writeNow) {
1326
+ const deferred = locals_js_1.locals.get(chatCustomAgentDeferredClientDataErrorsKey) ?? [];
1327
+ deferred.push(payload);
1328
+ locals_js_1.locals.set(chatCustomAgentDeferredClientDataErrorsKey, deferred);
1329
+ }
1330
+ // Drop the record: an invalid frame is never answered, by this run or
1331
+ // a later one. Released so a waiting read stops waiting, finds it
1332
+ // gone, and goes back to waiting for the next message.
1333
+ chatInputRouter().take(CHAT_ROUTE_MESSAGES, record.seqNum);
1334
+ releaseClaim();
1335
+ return;
1336
+ }
1337
+ // Valid, so release. The record is still queued, and whichever comes
1338
+ // first, an injection or a later turn's read, now owns it.
1339
+ releaseClaim();
1340
+ void Promise.resolve(handler(result.payload, seqNum, () => active)).catch(() => { });
1341
+ })
1342
+ .catch(() => { });
1343
+ });
1344
+ return {
1345
+ off() {
1346
+ active = false;
1347
+ subscription.off();
1348
+ },
1349
+ };
1350
+ }
1351
+ /**
1352
+ * Whether a record is currently owned by the validating observer.
1353
+ *
1354
+ * A read that pulls one puts it back and keeps waiting, so the observer's parse
1355
+ * stays the only one and the record is not consumed out from under it.
1356
+ */
1357
+ function observerClaimFor(record) {
1358
+ return locals_js_1.locals.get(chatObserverClaimedSeqNumsKey)?.get(record.seqNum)?.released;
1359
+ }
1037
1360
  const messagesInput = {
1038
1361
  id: "chat-messages",
1039
1362
  on(handler) {
1040
- return getChatSession().in.on((chunk) => {
1041
- if (chunk.kind === "message") {
1042
- // Returning `true` marks the record CONSUMED at the manager level:
1043
- // it is neither buffered for a later `once()` nor re-delivered by
1044
- // the buffer drain when the next turn re-attaches its handler.
1045
- // Without this, a message arriving mid-stream was delivered twice
1046
- // and ran a duplicate turn.
1047
- void Promise.resolve(handler(chunk.payload)).catch(() => { });
1048
- return true;
1049
- }
1050
- return undefined;
1051
- });
1363
+ if (!locals_js_1.locals.get(chatCustomAgentClientDataParserKey)) {
1364
+ return subscribeToRawChatMessages(handler);
1365
+ }
1366
+ const deliver = (payload) => handler(payload);
1367
+ return subscribeToValidatedChatMessages(deliver, { onAfterOff: deliver });
1052
1368
  },
1053
1369
  once(options) {
1054
- const ctx = v3_1.taskContext.ctx;
1055
- const runId = ctx?.run.id;
1056
1370
  return new v3_1.InputStreamOncePromise((resolve, reject) => {
1057
- tracer_js_1.tracer
1058
- .startActiveSpan(options?.spanName ?? `chat.messages.once()`, async () => {
1059
- while (true) {
1060
- const result = await getChatSession().in.once(options);
1061
- if (!result.ok) {
1062
- resolve(result);
1371
+ /**
1372
+ * Same skip-and-wait rule as `waitWithIdleTimeout`: a payload that fails
1373
+ * validation is reported and not surfaced. The timeout is a total budget
1374
+ * across retries, so skipping a frame cannot extend the wait forever.
1375
+ */
1376
+ const deadline = options?.timeoutMs === undefined ? undefined : Date.now() + options.timeoutMs;
1377
+ const take = () => {
1378
+ chatInputRouter()
1379
+ .next(CHAT_ROUTE_MESSAGES, {
1380
+ timeoutMs: deadline === undefined ? undefined : Math.max(0, deadline - Date.now()),
1381
+ })
1382
+ .then(async (record) => {
1383
+ if (!record) {
1384
+ resolve({
1385
+ ok: false,
1386
+ error: new v3_1.InputStreamTimeoutError("chat-messages", options?.timeoutMs ?? 0),
1387
+ });
1063
1388
  return;
1064
1389
  }
1065
- if (result.output.kind === "message") {
1066
- resolve({ ok: true, output: result.output.payload });
1390
+ const claim = observerClaimFor(record);
1391
+ if (claim) {
1392
+ chatInputRouter().untake(CHAT_ROUTE_MESSAGES, record);
1393
+ await claim;
1394
+ take();
1067
1395
  return;
1068
1396
  }
1069
- // Non-message chunks (stops) are handled by the stopInput
1070
- // facade's persistent listener; loop and wait for the next.
1071
- }
1072
- }, {
1073
- attributes: {
1074
- [v3_1.SemanticInternalAttributes.STYLE_ICON]: "streams",
1075
- [v3_1.SemanticInternalAttributes.ENTITY_TYPE]: "input-stream",
1076
- ...(runId
1077
- ? {
1078
- [v3_1.SemanticInternalAttributes.ENTITY_ID]: `${runId}:chat-messages`,
1079
- }
1080
- : {}),
1081
- streamId: "chat-messages",
1082
- ...(0, v3_1.accessoryAttributes)({
1083
- items: [{ text: "chat-messages", variant: "normal" }],
1084
- style: "codepath",
1085
- }),
1086
- },
1087
- })
1088
- .catch(reject);
1397
+ const payload = record.data.payload;
1398
+ if (!shouldValidateChatCustomAgentPayload(payload)) {
1399
+ resolve({ ok: true, output: payload });
1400
+ return;
1401
+ }
1402
+ const validated = await validateChatCustomAgentPayload(payload);
1403
+ if (validated.ok) {
1404
+ resolve({ ok: true, output: validated.payload });
1405
+ return;
1406
+ }
1407
+ take();
1408
+ }, reject);
1409
+ };
1410
+ take();
1089
1411
  });
1090
1412
  },
1091
1413
  peek() {
1092
- const chunk = getChatSession().in.peek();
1093
- if (chunk && chunk.kind === "message")
1094
- return chunk.payload;
1095
- return undefined;
1414
+ const record = chatInputRouter().peek(CHAT_ROUTE_MESSAGES);
1415
+ if (!record)
1416
+ return undefined;
1417
+ const payload = record.data.payload;
1418
+ return validateChatCustomAgentPayloadSync(payload);
1419
+ },
1420
+ async hasPending() {
1421
+ return chatInputRouter().hasPending(CHAT_ROUTE_MESSAGES);
1422
+ },
1423
+ async next(options) {
1424
+ const timeoutInSeconds = options?.timeoutInSeconds;
1425
+ if (timeoutInSeconds !== undefined &&
1426
+ (!Number.isFinite(timeoutInSeconds) || timeoutInSeconds < 0)) {
1427
+ throw new TypeError("chat.messages.next() timeoutInSeconds must be a finite non-negative number");
1428
+ }
1429
+ // Consuming read, so it takes the same claim-and-validate path as the other
1430
+ // reads: a record the observer still owns is put back and awaited, and an
1431
+ // invalid payload is reported and skipped rather than surfaced raw.
1432
+ const totalMs = timeoutInSeconds === undefined ? undefined : timeoutInSeconds * 1000;
1433
+ /**
1434
+ * The caller's timeout is a total budget, not a per-attempt one. Skipping an
1435
+ * invalid frame must not buy another full wait, or a client sending invalid
1436
+ * frames faster than the timeout would keep the read blocked indefinitely
1437
+ * and it would never return.
1438
+ */
1439
+ const deadline = totalMs === undefined ? undefined : Date.now() + totalMs;
1440
+ while (true) {
1441
+ const record = await chatInputRouter().next(CHAT_ROUTE_MESSAGES, {
1442
+ timeoutMs: deadline === undefined ? undefined : Math.max(0, deadline - Date.now()),
1443
+ });
1444
+ if (!record)
1445
+ return undefined;
1446
+ const claim = observerClaimFor(record);
1447
+ if (claim) {
1448
+ chatInputRouter().untake(CHAT_ROUTE_MESSAGES, record);
1449
+ await claim;
1450
+ continue;
1451
+ }
1452
+ const chunk = record.data;
1453
+ if (!shouldValidateChatCustomAgentPayload(chunk.payload)) {
1454
+ return { id: record.id, seqNum: record.seqNum, payload: chunk.payload };
1455
+ }
1456
+ const validated = await validateChatCustomAgentPayload(chunk.payload);
1457
+ if (validated.ok) {
1458
+ return { id: record.id, seqNum: record.seqNum, payload: validated.payload };
1459
+ }
1460
+ }
1096
1461
  },
1097
1462
  wait(options) {
1098
1463
  return new v3_1.ManualWaitpointPromise(async (resolve, reject) => {
1099
1464
  try {
1465
+ // Same skip-and-wait rule as `waitWithIdleTimeout`, looping back into
1466
+ // the suspending wait rather than surfacing an unvalidated payload.
1100
1467
  while (true) {
1101
- const result = await getChatSession().in.wait(options);
1468
+ const result = await waitOnChatRoute(CHAT_ROUTE_MESSAGES, { timeout: options?.timeout, spanName: options?.spanName });
1102
1469
  if (!result.ok) {
1103
- resolve(result);
1470
+ resolve({ ok: false, error: result.error ?? new Error("Timed out") });
1471
+ return;
1472
+ }
1473
+ const claim = observerClaimFor(result.record);
1474
+ if (claim) {
1475
+ chatInputRouter().untake(CHAT_ROUTE_MESSAGES, result.record);
1476
+ await claim;
1477
+ continue;
1478
+ }
1479
+ const payload = result.output.payload;
1480
+ if (!shouldValidateChatCustomAgentPayload(payload)) {
1481
+ resolve({ ok: true, output: payload });
1104
1482
  return;
1105
1483
  }
1106
- if (result.output.kind === "message") {
1107
- resolve({ ok: true, output: result.output.payload });
1484
+ const validated = await validateChatCustomAgentPayload(payload);
1485
+ if (validated.ok) {
1486
+ resolve({ ok: true, output: validated.payload });
1108
1487
  return;
1109
1488
  }
1110
- // Stop chunks are handled by the stopInput facade's persistent
1111
- // listener; loop back into the suspending wait.
1112
1489
  }
1113
1490
  }
1114
1491
  catch (error) {
@@ -1117,102 +1494,75 @@ const messagesInput = {
1117
1494
  });
1118
1495
  },
1119
1496
  async waitWithIdleTimeout(options) {
1497
+ /**
1498
+ * A payload that fails client-data validation is reported and skipped
1499
+ * rather than surfaced, so the caller never sees an unvalidated
1500
+ * `clientData`. The loop waits for the next message instead of returning an
1501
+ * error, which keeps a single bad client send from ending the turn.
1502
+ */
1120
1503
  while (true) {
1121
- const result = await getChatSession().in.waitWithIdleTimeout(options);
1504
+ const result = await waitOnChatRoute(CHAT_ROUTE_MESSAGES, options);
1122
1505
  if (!result.ok)
1123
- return result;
1124
- if (result.output.kind === "message") {
1125
- return { ok: true, output: result.output.payload };
1506
+ return { ok: false, error: result.error };
1507
+ const claim = observerClaimFor(result.record);
1508
+ if (claim) {
1509
+ chatInputRouter().untake(CHAT_ROUTE_MESSAGES, result.record);
1510
+ await claim;
1511
+ continue;
1512
+ }
1513
+ const payload = result.output.payload;
1514
+ if (!shouldValidateChatCustomAgentPayload(payload)) {
1515
+ return { ok: true, output: payload };
1516
+ }
1517
+ const validated = await validateChatCustomAgentPayload(payload);
1518
+ if (validated.ok) {
1519
+ return { ok: true, output: validated.payload };
1126
1520
  }
1127
- // Swallow stop-kind chunks — persistent stop listener already handled
1128
- // the abort; we just loop for the next message.
1129
1521
  }
1130
1522
  },
1131
1523
  async send(_runId, data, options) {
1132
- // The `runId` argument is kept for signature parity with
1133
- // `RealtimeDefinedInputStream` but ignored — sessions are addressed
1134
- // by sessionId, not runId. Callers producing messages from outside
1135
- // the run should prefer the transport's `session.in.send(...)` path.
1136
1524
  await getChatSession().in.send({ kind: "message", payload: data }, options?.requestOptions);
1137
1525
  },
1138
1526
  };
1139
1527
  const stopInput = {
1140
1528
  id: "chat-stop",
1141
1529
  on(handler) {
1142
- return getChatSession().in.on((chunk) => {
1143
- if (chunk.kind === "stop") {
1144
- // Consume stop records (see the messages facade above). A stop is
1145
- // only meaningful to the turn it interrupts — buffering it would
1146
- // let a stale stop abort a future turn.
1147
- void Promise.resolve(handler({ stop: true, message: chunk.message })).catch(() => { });
1148
- return true;
1149
- }
1150
- return undefined;
1530
+ return chatInputRouter().on(CHAT_ROUTE_STOP, (record) => {
1531
+ const chunk = record.data;
1532
+ void Promise.resolve(handler({ stop: true, message: chunk.message })).catch(() => { });
1151
1533
  });
1152
1534
  },
1153
1535
  once(options) {
1154
- const ctx = v3_1.taskContext.ctx;
1155
- const runId = ctx?.run.id;
1156
1536
  return new v3_1.InputStreamOncePromise((resolve, reject) => {
1157
- tracer_js_1.tracer
1158
- .startActiveSpan(options?.spanName ?? `chat.stop.once()`, async () => {
1159
- while (true) {
1160
- const result = await getChatSession().in.once(options);
1161
- if (!result.ok) {
1162
- resolve(result);
1163
- return;
1164
- }
1165
- if (result.output.kind === "stop") {
1166
- resolve({
1167
- ok: true,
1168
- output: { stop: true, message: result.output.message },
1169
- });
1170
- return;
1171
- }
1537
+ chatInputRouter()
1538
+ .next(CHAT_ROUTE_STOP, { timeoutMs: options?.timeoutMs })
1539
+ .then((record) => {
1540
+ if (!record) {
1541
+ resolve({
1542
+ ok: false,
1543
+ error: new v3_1.InputStreamTimeoutError("chat-stop", options?.timeoutMs ?? 0),
1544
+ });
1545
+ return;
1172
1546
  }
1173
- }, {
1174
- attributes: {
1175
- [v3_1.SemanticInternalAttributes.STYLE_ICON]: "streams",
1176
- [v3_1.SemanticInternalAttributes.ENTITY_TYPE]: "input-stream",
1177
- ...(runId
1178
- ? {
1179
- [v3_1.SemanticInternalAttributes.ENTITY_ID]: `${runId}:chat-stop`,
1180
- }
1181
- : {}),
1182
- streamId: "chat-stop",
1183
- ...(0, v3_1.accessoryAttributes)({
1184
- items: [{ text: "chat-stop", variant: "normal" }],
1185
- style: "codepath",
1186
- }),
1187
- },
1188
- })
1189
- .catch(reject);
1547
+ const chunk = record.data;
1548
+ resolve({ ok: true, output: { stop: true, message: chunk.message } });
1549
+ }, reject);
1190
1550
  });
1191
1551
  },
1192
1552
  peek() {
1193
- const chunk = getChatSession().in.peek();
1194
- if (chunk && chunk.kind === "stop") {
1195
- return { stop: true, message: chunk.message };
1196
- }
1197
- return undefined;
1553
+ const record = chatInputRouter().peek(CHAT_ROUTE_STOP);
1554
+ if (!record)
1555
+ return undefined;
1556
+ const chunk = record.data;
1557
+ return { stop: true, message: chunk.message };
1198
1558
  },
1199
1559
  wait(options) {
1200
1560
  return new v3_1.ManualWaitpointPromise(async (resolve, reject) => {
1201
1561
  try {
1202
- while (true) {
1203
- const result = await getChatSession().in.wait(options);
1204
- if (!result.ok) {
1205
- resolve(result);
1206
- return;
1207
- }
1208
- if (result.output.kind === "stop") {
1209
- resolve({
1210
- ok: true,
1211
- output: { stop: true, message: result.output.message },
1212
- });
1213
- return;
1214
- }
1215
- }
1562
+ const result = await waitOnChatRoute(CHAT_ROUTE_STOP, { timeout: options?.timeout, spanName: options?.spanName });
1563
+ resolve(result.ok
1564
+ ? { ok: true, output: { stop: true, message: result.output.message } }
1565
+ : { ok: false, error: result.error ?? new Error("Timed out") });
1216
1566
  }
1217
1567
  catch (error) {
1218
1568
  reject(error);
@@ -1220,14 +1570,10 @@ const stopInput = {
1220
1570
  });
1221
1571
  },
1222
1572
  async waitWithIdleTimeout(options) {
1223
- while (true) {
1224
- const result = await getChatSession().in.waitWithIdleTimeout(options);
1225
- if (!result.ok)
1226
- return result;
1227
- if (result.output.kind === "stop") {
1228
- return { ok: true, output: { stop: true, message: result.output.message } };
1229
- }
1230
- }
1573
+ const result = await waitOnChatRoute(CHAT_ROUTE_STOP, options);
1574
+ return result.ok
1575
+ ? { ok: true, output: { stop: true, message: result.output.message } }
1576
+ : { ok: false, error: result.error };
1231
1577
  },
1232
1578
  async send(_runId, data, options) {
1233
1579
  await getChatSession().in.send({ kind: "stop", message: data?.message }, options?.requestOptions);
@@ -1241,17 +1587,8 @@ const stopInput = {
1241
1587
  */
1242
1588
  const handoverInput = {
1243
1589
  async waitWithIdleTimeout(options) {
1244
- while (true) {
1245
- const result = await getChatSession().in.waitWithIdleTimeout(options);
1246
- if (!result.ok)
1247
- return result;
1248
- if (result.output.kind === "handover" || result.output.kind === "handover-skip") {
1249
- return { ok: true, output: result.output };
1250
- }
1251
- // Other kinds (message, stop) are not expected during handover-prepare.
1252
- // Loop back; the message and stop facades have their own listeners
1253
- // running so signals on those kinds aren't lost.
1254
- }
1590
+ const result = await waitOnChatRoute(CHAT_ROUTE_HANDOVER, options);
1591
+ return result.ok ? { ok: true, output: result.output } : result;
1255
1592
  },
1256
1593
  };
1257
1594
  /**
@@ -1267,22 +1604,229 @@ const handoverInput = {
1267
1604
  * For the common case prefer `accumulator.consumeHandover()`, which also seeds
1268
1605
  * `payload.headStartMessages` and applies the partial for you.
1269
1606
  *
1270
- * Must be called at turn 0 before any `chat.messages.waitWithIdleTimeout`
1271
- * that facade consumes and discards non-message chunks, which would swallow the
1272
- * handover signal.
1607
+ * Safe to call at any point in turn 0: the handover signal has its own route,
1608
+ * so a message facade waiting at the same time cannot take it.
1273
1609
  */
1274
1610
  async function waitForHandover(options) {
1275
1611
  if (options.payload.trigger !== "handover-prepare")
1276
1612
  return null;
1277
- const result = await handoverInput.waitWithIdleTimeout({
1278
- idleTimeoutInSeconds: options.idleTimeoutInSeconds ?? options.payload.idleTimeoutInSeconds ?? 60,
1279
- timeout: options.timeout,
1280
- spanName: options.spanName ?? "waiting for handover signal",
1613
+ try {
1614
+ const result = await handoverInput.waitWithIdleTimeout({
1615
+ idleTimeoutInSeconds: options.idleTimeoutInSeconds ?? options.payload.idleTimeoutInSeconds ?? 60,
1616
+ timeout: options.timeout,
1617
+ spanName: options.spanName ?? "waiting for handover signal",
1618
+ });
1619
+ // Non-ok = idle timeout or the warm handler crashed without signaling.
1620
+ if (!result.ok)
1621
+ return null;
1622
+ return result.output;
1623
+ }
1624
+ finally {
1625
+ chatInputRouter().clearRoute(CHAT_ROUTE_HANDOVER);
1626
+ }
1627
+ }
1628
+ /**
1629
+ * Everything `session.in` carries, and what happens to each kind.
1630
+ *
1631
+ * A route's two properties are what make the resume protocol derivable rather
1632
+ * than hand-maintained. `messages` is replayable because losing a user message
1633
+ * is data loss. `stop` is neither queued nor replayable: it only means anything
1634
+ * to the turn that is live when it lands, and a replayed one would abort
1635
+ * whichever turn happened to be running. `handover` is queued but not
1636
+ * replayable, because it can arrive before its consumer is ready yet is
1637
+ * meaningless to any later boot.
1638
+ *
1639
+ * A kind absent from this table has no consumer, so the router discards it
1640
+ * instead of letting it park at the head of a queue.
1641
+ * @internal
1642
+ */
1643
+ const CHAT_INPUT_ROUTES = {
1644
+ kindOf: (data) => data?.kind,
1645
+ routes: [
1646
+ { name: "messages", delivery: "queue", replayable: true, kinds: ["message"] },
1647
+ { name: "stop", delivery: "at-arrival", replayable: false, kinds: ["stop"] },
1648
+ {
1649
+ name: "handover",
1650
+ delivery: "queue",
1651
+ replayable: false,
1652
+ kinds: ["handover", "handover-skip"],
1653
+ },
1654
+ ],
1655
+ };
1656
+ const CHAT_ROUTE_MESSAGES = "messages";
1657
+ const CHAT_ROUTE_STOP = "stop";
1658
+ const CHAT_ROUTE_HANDOVER = "handover";
1659
+ /**
1660
+ * The `.in` router for the run this worker is currently serving.
1661
+ *
1662
+ * One slot rather than a map, because the facades have to reach the same router
1663
+ * the boot attached without depending on a locals scope being active. Tagged
1664
+ * with the run as well as the chat: a warm process is reused across runs and the
1665
+ * executor tears the channel subscription down at the end of each one, so
1666
+ * reusing a router across runs would leave the new run with no input at all. A
1667
+ * nested `chat.createSession` within the same run still shares it.
1668
+ * @internal
1669
+ */
1670
+ let currentChatInputRouter;
1671
+ /**
1672
+ * Both cursors from the latest `turn-complete` on `.out`, in one scan.
1673
+ *
1674
+ * Absent for a chat whose turns predate the headers, in which case the router
1675
+ * starts from the beginning of the channel and nothing is treated as replayed.
1676
+ * @internal
1677
+ */
1678
+ async function findLatestSessionInCheckpoint(chatId) {
1679
+ const apiClient = v3_1.apiClientManager.clientOrThrow();
1680
+ const response = await apiClient.readSessionStreamRecords(chatId, "out");
1681
+ const checkpoint = {};
1682
+ for (const record of response.records) {
1683
+ if ((0, v3_1.controlSubtype)(record.headers) !== v3_1.TRIGGER_CONTROL_SUBTYPE.TURN_COMPLETE)
1684
+ continue;
1685
+ const resumeFrom = Number.parseInt((0, v3_1.headerValue)(record.headers, v3_1.SESSION_IN_EVENT_ID_HEADER) ?? "", 10);
1686
+ if (Number.isFinite(resumeFrom))
1687
+ checkpoint.resumeFrom = resumeFrom;
1688
+ const appliedThrough = Number.parseInt((0, v3_1.headerValue)(record.headers, v3_1.SESSION_IN_CONSUMED_ID_HEADER) ?? "", 10);
1689
+ if (Number.isFinite(appliedThrough))
1690
+ checkpoint.appliedThrough = appliedThrough;
1691
+ }
1692
+ return checkpoint;
1693
+ }
1694
+ /**
1695
+ * The highest `.in` sequence already on the channel at boot, above the floor.
1696
+ *
1697
+ * Everything already on the channel when a run boots is, by definition, not
1698
+ * arriving live on this run, so the channel's own tail is the end of this run's
1699
+ * replay window.
1700
+ *
1701
+ * The turn boundary's value is not enough on its own. A boundary is written when
1702
+ * a turn ends, so a control record that arrived after the last boundary is not
1703
+ * covered by it, and a boundary written by an older SDK does not carry one at
1704
+ * all. Both cases leave an already-applied record looking live.
1705
+ *
1706
+ * Bounded by `afterEventId` when a floor is known, which is the common case, so
1707
+ * the read covers the replay window rather than the conversation. An absent
1708
+ * floor means no boundary has committed a cursor yet, and the channel is still
1709
+ * short.
1710
+ *
1711
+ * Returns `undefined` if the read fails; the caller then falls back to the
1712
+ * floor, which is the previous release's behaviour.
1713
+ * @internal
1714
+ */
1715
+ async function findSessionInReplayWindowEnd(chatId, afterSeqNum) {
1716
+ try {
1717
+ const apiClient = v3_1.apiClientManager.clientOrThrow();
1718
+ const response = await apiClient.readSessionStreamRecords(chatId, "in", {
1719
+ ...(afterSeqNum === undefined ? {} : { afterEventId: String(afterSeqNum) }),
1720
+ });
1721
+ let highest;
1722
+ for (const record of response.records) {
1723
+ const seqNum = typeof record.seqNum === "number" ? record.seqNum : Number.NaN;
1724
+ if (Number.isFinite(seqNum) && (highest === undefined || seqNum > highest)) {
1725
+ highest = seqNum;
1726
+ }
1727
+ }
1728
+ return highest;
1729
+ }
1730
+ catch {
1731
+ return undefined;
1732
+ }
1733
+ }
1734
+ /**
1735
+ * Attach the `.in` router for this run.
1736
+ *
1737
+ * Reads the checkpoint and subscribes in one call, so there is no window in
1738
+ * which a listener is attached before the resume cursor is seeded. Attaching
1739
+ * first would open the tail at sequence 0 and replay every record the previous
1740
+ * run already answered, which is a mistake the previous shape of this code made
1741
+ * possible and this shape does not.
1742
+ *
1743
+ * The router consumes every record at dispatch, so the channel's own buffer
1744
+ * stays empty for chat and its cursor bookkeeping never engages. Delivery,
1745
+ * ordering and the published cursors are the router's, entirely.
1746
+ * @internal
1747
+ */
1748
+ async function installChatInputRouter(chatId, options) {
1749
+ const entry = chatInputRouterEntry(chatId);
1750
+ if (entry.attached)
1751
+ return entry.router;
1752
+ let checkpoint = {};
1753
+ try {
1754
+ checkpoint = await findLatestSessionInCheckpoint(chatId);
1755
+ }
1756
+ catch (error) {
1757
+ v3_1.logger.warn("chat session: session.in resume cursor lookup failed; old messages may replay", {
1758
+ error: error instanceof Error ? error.message : String(error),
1759
+ });
1760
+ }
1761
+ if (checkpoint.resumeFrom === undefined && options?.fallbackResumeFrom !== undefined) {
1762
+ checkpoint.resumeFrom = options.fallbackResumeFrom;
1763
+ }
1764
+ // Only a resuming run has a replay window. On a first boot nothing has been
1765
+ // applied by anyone, so treating what is already on the channel as replayed
1766
+ // would discard a signal that arrived before the agent got here, which is
1767
+ // exactly how a head-start handover reaches a cold run.
1768
+ const resuming = checkpoint.resumeFrom !== undefined || options?.resuming === true;
1769
+ if (resuming) {
1770
+ const replayWindowEnd = await findSessionInReplayWindowEnd(chatId, checkpoint.resumeFrom);
1771
+ if (replayWindowEnd !== undefined) {
1772
+ checkpoint.appliedThrough = Math.max(checkpoint.appliedThrough ?? replayWindowEnd, replayWindowEnd);
1773
+ }
1774
+ }
1775
+ const router = entry.router;
1776
+ router.restore(checkpoint);
1777
+ const floor = router.resumeFrom();
1778
+ if (floor !== undefined) {
1779
+ v3_1.sessionStreams.setLastSeqNum(chatId, "in", floor);
1780
+ v3_1.sessionStreams.setLastDispatchedSeqNum(chatId, "in", floor);
1781
+ }
1782
+ v3_1.sessionStreams.onRecord(chatId, "in", (record) => {
1783
+ router.ingest(record);
1784
+ return true;
1281
1785
  });
1282
- // Non-ok = idle timeout or the warm handler crashed without signaling.
1283
- if (!result.ok)
1284
- return null;
1285
- return result.output;
1786
+ entry.attached = true;
1787
+ return router;
1788
+ }
1789
+ function chatInputRouterEntry(chatId) {
1790
+ const runId = v3_1.taskContext.ctx?.run.id;
1791
+ if (currentChatInputRouter?.chatId === chatId && currentChatInputRouter.runId === runId) {
1792
+ return currentChatInputRouter;
1793
+ }
1794
+ currentChatInputRouter = {
1795
+ chatId,
1796
+ runId,
1797
+ router: new v3_1.SessionChannelRouter(CHAT_INPUT_ROUTES, {
1798
+ onDrop: (record, reason) => {
1799
+ if (reason === "unroutable" || reason === "malformed") {
1800
+ v3_1.logger.warn("chat: discarded a session.in record nothing on this worker can consume", {
1801
+ reason,
1802
+ seqNum: record.seqNum,
1803
+ });
1804
+ }
1805
+ },
1806
+ }),
1807
+ attached: false,
1808
+ };
1809
+ return currentChatInputRouter;
1810
+ }
1811
+ /** Drop the router so the next boot attaches a fresh one. @internal */
1812
+ function __resetChatInputRouterForTests() {
1813
+ currentChatInputRouter = undefined;
1814
+ }
1815
+ /** The cursors this run would publish on its next turn boundary. @internal */
1816
+ function __chatInputCheckpointForTests() {
1817
+ return currentChatInputRouter?.router.checkpoint() ?? {};
1818
+ }
1819
+ /** Test-only entry point for the turn-boundary cursor scan. @internal */
1820
+ async function __findLatestSessionInCheckpointForTests(chatId) {
1821
+ return findLatestSessionInCheckpoint(chatId);
1822
+ }
1823
+ /**
1824
+ * This chat's router. Created on first use so a facade reached before the
1825
+ * install still shares the one the install will attach.
1826
+ * @internal
1827
+ */
1828
+ function chatInputRouter() {
1829
+ return chatInputRouterEntry(getChatSession().id).router;
1286
1830
  }
1287
1831
  /**
1288
1832
  * Per-turn deferred promises. Registered via `chat.defer()`, awaited
@@ -1878,6 +2422,30 @@ const chatCompactionStateKey = locals_js_1.locals.create("chat.compaction");
1878
2422
  const chatOnCompactedKey = locals_js_1.locals.create("chat.onCompacted");
1879
2423
  /** @internal Full task `ctx` for the active `chat.agent` run (for hooks invoked from nested compaction). */
1880
2424
  const chatAgentRunContextKey = locals_js_1.locals.create("chat.agentRunContext");
2425
+ /** @internal When a mid-turn validation failure writes its terminal error. */
2426
+ const chatCustomAgentClientDataErrorTimingKey = locals_js_1.locals.create("chat.customAgent.clientDataErrorTiming");
2427
+ /**
2428
+ * @internal Client-data errors held back until the open turn closes.
2429
+ *
2430
+ * The default timing exists so a bad send cannot truncate an answer already
2431
+ * being read, which means the write has to happen later rather than not at all:
2432
+ * the callback and the task log are server-side, so dropping the stream write
2433
+ * would leave the client with no signal that its frame was rejected.
2434
+ */
2435
+ const chatCustomAgentDeferredClientDataErrorsKey = locals_js_1.locals.create("chat.customAgent.deferredClientDataErrors");
2436
+ /**
2437
+ * @internal Sequences the validating observer has claimed.
2438
+ *
2439
+ * The observer watches without consuming, so a record it is still validating is
2440
+ * also visible to a turn's read. Claiming makes ownership single: a read that
2441
+ * pulls a claimed record puts it back and keeps waiting, so exactly one of them
2442
+ * validates it and the schema runs once per frame.
2443
+ */
2444
+ const chatObserverClaimedSeqNumsKey = locals_js_1.locals.create("chat.customAgent.observerClaims");
2445
+ /** @internal Marks the root run created by `chat.customAgent()`. */
2446
+ const chatCustomAgentRunKey = locals_js_1.locals.create("chat.customAgentRun");
2447
+ /** @internal Number of active `chat.createSession()` iterators in this run. */
2448
+ const chatActiveSessionIteratorsKey = locals_js_1.locals.create("chat.createSession.activeIterators");
1881
2449
  const chatPrepareMessagesKey = locals_js_1.locals.create("chat.prepareMessages");
1882
2450
  /**
1883
2451
  * @internal The raw `tools` option from `chat.agent({ tools })`, either a
@@ -2273,7 +2841,14 @@ async function drainSteeringQueue(config, messages, steps, queueOverride) {
2273
2841
  return [];
2274
2842
  const ctx = locals_js_1.locals.get(chatTurnContextKey);
2275
2843
  const stepNumber = steps.length - 1;
2276
- const uiMessages = queue.map((e) => e.uiMessage);
2844
+ /**
2845
+ * Snapshot, because `shouldInject` and `prepare` can await. A record arriving
2846
+ * during either is not in this batch, so it must not be consumed by it: the
2847
+ * callbacks never saw it and could not have injected it, and taking it would
2848
+ * lose a message that should become a later turn instead.
2849
+ */
2850
+ const batch = [...queue];
2851
+ const uiMessages = batch.map((e) => e.uiMessage);
2277
2852
  const batchEvent = {
2278
2853
  messages: uiMessages,
2279
2854
  modelMessages: messages,
@@ -2287,22 +2862,79 @@ async function drainSteeringQueue(config, messages, steps, queueOverride) {
2287
2862
  const shouldInject = config.shouldInject ? await config.shouldInject(batchEvent) : false;
2288
2863
  if (!shouldInject)
2289
2864
  return [];
2290
- // Extract message texts for span attributes
2291
- const messageTexts = uiMessages.map((m) => (m.parts ?? [])
2865
+ const textOfUIMessage = (m) => (m.parts ?? [])
2292
2866
  .filter((p) => p.type === "text")
2293
2867
  .map((p) => p.text)
2294
- .join("") || "");
2868
+ .join("") || "";
2869
+ // Span attributes describe the offered batch; the chunk and callback below
2870
+ // describe what was actually claimed and injected.
2871
+ const messageTexts = uiMessages.map(textOfUIMessage);
2295
2872
  const _previewText = messageTexts.length === 1 ? messageTexts[0].slice(0, 80) : `${queue.length} messages`;
2296
2873
  return tracer_js_1.tracer.startActiveSpan("pending message injected", async () => {
2297
- // Transform the batch — default: concatenate all pre-converted model messages
2298
- const injected = config.prepare
2299
- ? await config.prepare(batchEvent)
2300
- : queue.flatMap((e) => e.modelMessages);
2301
- // Clear the queue and record injected IDs
2302
- queue.length = 0;
2874
+ /**
2875
+ * Claim before transforming, and inject only what was claimed.
2876
+ *
2877
+ * Injection is the point of consumption: the records were only observed
2878
+ * on arrival, so they are still queued on the router and still holding
2879
+ * the resume floor, and taking them here is what stops the same message
2880
+ * also being answered as a later turn. A declined batch never reaches
2881
+ * this line, so its records stay queued and become later turns, which is
2882
+ * what "messages queue for the next turn" means.
2883
+ *
2884
+ * A failed claim means something else already consumed that record while
2885
+ * `shouldInject` was awaiting, so it is already being answered as a turn
2886
+ * of its own. Injecting it as well would process the same message twice.
2887
+ * An entry with no `seqNum` did not come from a channel record (the
2888
+ * accumulator's own queue), so there is nothing to claim and it is kept.
2889
+ */
2890
+ const router = chatInputRouter();
2891
+ const claimed = [];
2892
+ const takenRecords = [];
2893
+ for (const entry of batch) {
2894
+ if (entry.seqNum === undefined) {
2895
+ claimed.push(entry);
2896
+ continue;
2897
+ }
2898
+ const record = router.take(CHAT_ROUTE_MESSAGES, entry.seqNum);
2899
+ if (!record)
2900
+ continue;
2901
+ takenRecords.push(record);
2902
+ claimed.push(entry);
2903
+ }
2904
+ for (const entry of claimed) {
2905
+ const at = queue.indexOf(entry);
2906
+ if (at !== -1)
2907
+ queue.splice(at, 1);
2908
+ }
2909
+ if (claimed.length === 0)
2910
+ return [];
2911
+ /**
2912
+ * Give the claim back if the transform fails. `prepare` is caller code and
2913
+ * can throw; the records have already left the router by this point, so
2914
+ * without returning them a failed transform would consume the messages and
2915
+ * they would never be answered at all.
2916
+ */
2917
+ const releaseClaim = () => {
2918
+ for (const record of takenRecords)
2919
+ router.untake(CHAT_ROUTE_MESSAGES, record);
2920
+ for (const entry of claimed)
2921
+ if (!queue.includes(entry))
2922
+ queue.push(entry);
2923
+ };
2924
+ const claimedUIMessages = claimed.map((e) => e.uiMessage);
2925
+ let injected;
2926
+ try {
2927
+ injected = config.prepare
2928
+ ? await config.prepare({ ...batchEvent, messages: claimedUIMessages })
2929
+ : claimed.flatMap((e) => e.modelMessages);
2930
+ }
2931
+ catch (err) {
2932
+ releaseClaim();
2933
+ throw err;
2934
+ }
2303
2935
  const injectedIds = locals_js_1.locals.get(chatInjectedMessageIdsKey);
2304
2936
  if (injectedIds) {
2305
- for (const m of uiMessages)
2937
+ for (const m of claimedUIMessages)
2306
2938
  injectedIds.add(m.id);
2307
2939
  }
2308
2940
  // Write injection confirmation chunk to the stream so the frontend
@@ -2316,10 +2948,10 @@ async function drainSteeringQueue(config, messages, steps, queueOverride) {
2316
2948
  type: ai_shared_js_1.PENDING_MESSAGE_INJECTED_TYPE,
2317
2949
  id: (0, ai_runtime_js_1.generateId)(),
2318
2950
  data: {
2319
- messageIds: uiMessages.map((m) => m.id),
2320
- messages: uiMessages.map((m, idx) => ({
2951
+ messageIds: claimedUIMessages.map((m) => m.id),
2952
+ messages: claimedUIMessages.map((m) => ({
2321
2953
  id: m.id,
2322
- text: messageTexts[idx] ?? "",
2954
+ text: textOfUIMessage(m),
2323
2955
  })),
2324
2956
  },
2325
2957
  });
@@ -2335,7 +2967,7 @@ async function drainSteeringQueue(config, messages, steps, queueOverride) {
2335
2967
  if (config.onInjected && injected.length > 0) {
2336
2968
  try {
2337
2969
  await config.onInjected({
2338
- messages: uiMessages,
2970
+ messages: claimedUIMessages,
2339
2971
  injectedModelMessages: injected,
2340
2972
  chatId: ctx?.chatId ?? "",
2341
2973
  turn: ctx?.turn ?? 0,
@@ -2769,7 +3401,11 @@ async function pipeChat(source, options) {
2769
3401
  await waitUntilComplete();
2770
3402
  }
2771
3403
  function chatCustomAgent(options) {
2772
- const { clientDataSchema, run: userRun, ...restOptions } = options;
3404
+ const { clientDataSchema, onClientDataValidationError, clientDataReportErrorAt, run: userRun, ...restOptions } = options;
3405
+ const parseClientData = clientDataSchema ? (0, v3_1.getSchemaParseFn)(clientDataSchema) : undefined;
3406
+ const parseClientDataSync = clientDataSchema
3407
+ ? getChatCustomAgentSyncSchemaParseFn(clientDataSchema)
3408
+ : undefined;
2773
3409
  const task = (0, shared_js_1.createTask)({
2774
3410
  ...restOptions,
2775
3411
  triggerSource: "agent",
@@ -2788,6 +3424,17 @@ function chatCustomAgent(options) {
2788
3424
  locals_js_1.locals.set(chatSessionHandleKey, sessions_js_1.sessions.open(payload.chatId));
2789
3425
  locals_js_1.locals.set(chatExternalIdKey, payload.chatId);
2790
3426
  locals_js_1.locals.set(chatAgentRunContextKey, runOptions.ctx);
3427
+ locals_js_1.locals.set(chatCustomAgentRunKey, true);
3428
+ if (parseClientData && parseClientDataSync) {
3429
+ locals_js_1.locals.set(chatCustomAgentClientDataParserKey, {
3430
+ parse: parseClientData,
3431
+ parseSync: parseClientDataSync,
3432
+ });
3433
+ }
3434
+ if (onClientDataValidationError) {
3435
+ locals_js_1.locals.set(chatCustomAgentClientDataErrorHandlerKey, onClientDataValidationError);
3436
+ }
3437
+ locals_js_1.locals.set(chatCustomAgentClientDataErrorTimingKey, clientDataReportErrorAt ?? "turn-end");
2791
3438
  // Initialize the turn-complete trim slot so `chat.writeTurnComplete`
2792
3439
  // trims `session.out` back to the previous turn boundary. Without
2793
3440
  // this the slot is undefined and the trim never runs, so `.out`
@@ -2796,11 +3443,65 @@ function chatCustomAgent(options) {
2796
3443
  (0, streams_js_1.markChatAgentRunForStreamsWarning)();
2797
3444
  v3_1.taskContext.setConversationId(payload.chatId);
2798
3445
  stampConversationIdOnActiveSpan(payload.chatId);
2799
- // Seed the `.in` resume cursor before user code attaches any `.in`
2800
- // listener — otherwise a continuation boot replays already-answered
2801
- // messages into the loop's first wait.
2802
- await seedSessionInResumeCursorForCustomLoop(payload);
2803
- return userRun(payload, runOptions);
3446
+ await installChatInputRouter(payload.chatId, {
3447
+ resuming: Boolean(payload.continuation),
3448
+ });
3449
+ // Keep the schema-free path identical to the original custom-agent
3450
+ // wrapper, including when userRun starts executing.
3451
+ if (!parseClientData) {
3452
+ return userRun(payload, runOptions);
3453
+ }
3454
+ const isHandoverBoot = payload.trigger === "handover-prepare";
3455
+ const isMessagelessBoot = payload.trigger === "preload" ||
3456
+ (payload.continuation === true &&
3457
+ payload.message === undefined &&
3458
+ payload.trigger !== "action" &&
3459
+ payload.trigger !== "regenerate-message" &&
3460
+ !isHandoverBoot);
3461
+ const validated = await validateChatCustomAgentPayload(payload, {
3462
+ // Preload and continuation boots do not represent a submitted turn,
3463
+ // so there is no sender waiting for a terminal frame. Handover errors
3464
+ // must be written after the warm response flushes and signals below.
3465
+ writeErrorToStream: !isMessagelessBoot && !isHandoverBoot,
3466
+ });
3467
+ if (validated.ok) {
3468
+ return userRun(validated.payload, runOptions);
3469
+ }
3470
+ if (isHandoverBoot) {
3471
+ const signal = await waitForHandover({
3472
+ payload,
3473
+ timeout: "1h",
3474
+ spanName: "waiting for handover signal (invalid clientData)",
3475
+ });
3476
+ if (!signal || signal.kind === "handover-skip") {
3477
+ return;
3478
+ }
3479
+ // The head-start writer flushes before sending this signal. Writing
3480
+ // the terminal error now preserves stream order and closes the stitch.
3481
+ await writeChatCustomAgentClientDataErrorToStream(payload);
3482
+ return;
3483
+ }
3484
+ // The Session base payload is sticky across continuation runs. If it is
3485
+ // invalid, returning here would boot the same bad metadata again on the
3486
+ // next message. Stay attached and wait for a valid wire frame instead.
3487
+ const next = await messagesInput.waitWithIdleTimeout({
3488
+ idleTimeoutInSeconds: payload.idleTimeoutInSeconds ?? 30,
3489
+ timeout: "1h",
3490
+ spanName: "waiting for valid clientData",
3491
+ });
3492
+ if (!next.ok || next.output.trigger === "close") {
3493
+ return;
3494
+ }
3495
+ // Normal input frames omit run-level boot context. Carry it forward so
3496
+ // a continuation still tells the custom loop to restore prior state.
3497
+ const recoveredPayload = {
3498
+ ...next.output,
3499
+ continuation: next.output.continuation ?? payload.continuation,
3500
+ previousRunId: next.output.previousRunId ?? payload.previousRunId,
3501
+ sessionId: next.output.sessionId ?? payload.sessionId,
3502
+ idleTimeoutInSeconds: next.output.idleTimeoutInSeconds ?? payload.idleTimeoutInSeconds,
3503
+ };
3504
+ return userRun(recoveredPayload, runOptions);
2804
3505
  },
2805
3506
  });
2806
3507
  // Register clientDataSchema so the CLI converts it to JSONSchema
@@ -2830,6 +3531,7 @@ function chatAgent(options) {
2830
3531
  agentConfig: { type: "ai-sdk-chat" },
2831
3532
  run: async (payload, { signal: runSignal, ctx }) => {
2832
3533
  locals_js_1.locals.set(chatAgentRunContextKey, ctx);
3534
+ locals_js_1.locals.set(chatCustomAgentRunKey, false);
2833
3535
  // On AI SDK 7, register the `@ai-sdk/otel` integration (once per process)
2834
3536
  // so `experimental_telemetry` spans flow into the run trace. Awaited here
2835
3537
  // at run boot — before any `streamText` — and a no-op on v5/v6 or when the
@@ -2922,11 +3624,6 @@ function chatAgent(options) {
2922
3624
  // default, `inFlightUsers`). The turn-loop checks this queue ahead of
2923
3625
  // `messagesInput.waitWithIdleTimeout` so recovered turns fire first.
2924
3626
  const bootInjectedQueue = [];
2925
- // Messages consumed by a turn's `messagesInput.on` handler, dispatched
2926
- // one per turn by the end-of-turn pickup. Loop-level on purpose:
2927
- // consuming a record advances the committed `.in` cursor, so entries
2928
- // dropped with a turn-local buffer are lost permanently.
2929
- const pendingWireMessages = [];
2930
3627
  const couldHavePriorState = payload.continuation === true || ctx.attempt.number > 1;
2931
3628
  // `.in` resume cursor, computed at most once per boot. The boot
2932
3629
  // block below resolves it (snapshot field or records scan) and the
@@ -3049,49 +3746,16 @@ function chatAgent(options) {
3049
3746
  },
3050
3747
  });
3051
3748
  }
3052
- // ── session.in resume cursor ───────────────────────────────────
3053
- //
3054
- // A fresh worker subscribes to `session.in` from seq 0 and would
3055
- // re-deliver every record ever appended — including user messages
3056
- // from turns already completed on a prior run. Without a cursor,
3057
- // the loop would re-process them as fresh turns and the slim-wire
3058
- // merge would replace-by-id against snapshot-restored copies,
3059
- // yielding no-op replaces while the customer's actual new message
3060
- // waits in the queue.
3749
+ // ── session.in router ──────────────────────────────────────────
3061
3750
  //
3062
- // The cursor is the seq_num of the last `.in` record the prior
3063
- // worker committed to processing, persisted on each `turn-complete`
3064
- // control record as a `session-in-event-id` sibling header. The
3065
- // boot scan reads the header off `.out`'s latest turn-complete and
3066
- // seeds the manager so the upcoming `.in` SSE subscribe opens with
3067
- // `Last-Event-ID: <cursor>` S2 starts after that seq and old
3068
- // messages never reach this worker.
3069
- //
3070
- // Applies in three cases (any of which means `.in` has records
3071
- // belonging to completed turns the new run should skip):
3072
- // - OOM retry (`ctx.attempt.number > 1`)
3073
- // - Continuation run (`payload.continuation === true`) — prior run
3074
- // crashed / was canceled / requested upgrade
3075
- // - Snapshot exists at all (catches edge cases where the wire
3076
- // didn't set `continuation` but a snapshot indicates prior turns)
3077
- const needsResumeCursor = ctx.attempt.number > 1 || payload.continuation === true || bootSnapshot !== undefined;
3078
- if (needsResumeCursor) {
3079
- try {
3080
- // Reuse the cursor the boot block already resolved (snapshot
3081
- // field or records scan) — only scan here when the boot block
3082
- // was skipped (hydrateMessages, or snapshot-only signals).
3083
- const cursor = bootInCursorResolved
3084
- ? bootInCursor
3085
- : await findLatestSessionInCursor(payload.chatId);
3086
- if (cursor !== undefined) {
3087
- v3_1.sessionStreams.setLastSeqNum(payload.chatId, "in", cursor);
3088
- v3_1.sessionStreams.setLastDispatchedSeqNum(payload.chatId, "in", cursor);
3089
- }
3090
- }
3091
- catch (error) {
3092
- v3_1.logger.warn("chat.agent: session.in resume cursor lookup failed; old messages may replay", { error: error instanceof Error ? error.message : String(error) });
3093
- }
3094
- }
3751
+ // Reads the turn boundary and subscribes in one call. `bootInCursor` is
3752
+ // only a fallback: the boot block above may already have resolved a
3753
+ // cursor from the snapshot, which is used when the boundary itself
3754
+ // carries none.
3755
+ await installChatInputRouter(payload.chatId, {
3756
+ fallbackResumeFrom: bootInCursorResolved ? bootInCursor : undefined,
3757
+ resuming: Boolean(payload.continuation) || ctx.attempt.number > 1,
3758
+ });
3095
3759
  // ── Recovery boot + chain reconstruction ────────────────────────
3096
3760
  if (!hydrateMessages) {
3097
3761
  const settledMessages = mergeByIdReplaceWins(bootSnapshot?.messages ?? [], replayedSettled);
@@ -3160,14 +3824,23 @@ function chatAgent(options) {
3160
3824
  hookBeforeBoot = hookResult.beforeBoot;
3161
3825
  }
3162
3826
  }
3163
- // Default: splice partial + the user it was answering into
3164
- // the chain so follow-ups like "keep going" still have context.
3827
+ // Default: splice partial + the user it was answering into the chain
3828
+ // so follow-ups like "keep going" still have context, and re-dispatch
3829
+ // the users that arrived after it.
3830
+ //
3831
+ // The splice needs a follow-up user to answer — it consumes
3832
+ // `inFlightUsers[0]` into the chain instead of dispatching it. With
3833
+ // exactly ONE in-flight user (the plain OOM / crash-mid-answer case)
3834
+ // there is nothing left to dispatch, so splicing would strand that
3835
+ // user unanswered and idle the run. Require `length > 1` on both
3836
+ // branches: at n=1 the orphan partial is dropped and the interrupted
3837
+ // user is re-dispatched as a fresh turn instead.
3165
3838
  let seedChain;
3166
3839
  let recoveredTurns;
3167
3840
  if (hookChain !== undefined) {
3168
3841
  seedChain = hookChain;
3169
3842
  }
3170
- else if (partialAssistant !== undefined && inFlightUsers.length > 0) {
3843
+ else if (partialAssistant !== undefined && inFlightUsers.length > 1) {
3171
3844
  seedChain = [...settledMessages, inFlightUsers[0], partialAssistant];
3172
3845
  }
3173
3846
  else {
@@ -3176,7 +3849,7 @@ function chatAgent(options) {
3176
3849
  if (hookRecoveredTurns !== undefined) {
3177
3850
  recoveredTurns = hookRecoveredTurns;
3178
3851
  }
3179
- else if (partialAssistant !== undefined && inFlightUsers.length > 0) {
3852
+ else if (partialAssistant !== undefined && inFlightUsers.length > 1) {
3180
3853
  recoveredTurns = inFlightUsers.slice(1);
3181
3854
  }
3182
3855
  else {
@@ -3729,11 +4402,18 @@ function chatAgent(options) {
3729
4402
  const cancelSignal = runSignal;
3730
4403
  const combinedSignal = AbortSignal.any([runSignal, stopController.signal]);
3731
4404
  const pmConfig = locals_js_1.locals.get(chatPendingMessagesKey);
3732
- const msgSub = messagesInput.on(async (msg) => {
3733
- // If pendingMessages is configured, route to the steering queue
3734
- // instead of the wire buffer. The frontend handles re-sending
3735
- // non-injected messages via sendMessage on turn complete.
3736
- if (pmConfig) {
4405
+ /**
4406
+ * Only attached when there is a steering config to feed. Without
4407
+ * one a mid-turn message is left queued on the router, which is
4408
+ * what holds the resume floor behind it: a record handed to a
4409
+ * handler counts as terminally decided, so buffering one here
4410
+ * published a cursor past a message held only in memory and a
4411
+ * crash before the next turn lost it.
4412
+ */
4413
+ const msgSub = pmConfig
4414
+ ? chatInputRouter().observe(CHAT_ROUTE_MESSAGES, async (record) => {
4415
+ const msg = record.data
4416
+ .payload;
3737
4417
  // Slim wire: at most one delta message per record. The
3738
4418
  // pendingMessages handler reads `msg.message` directly
3739
4419
  // instead of slicing an array — a wire record arrives
@@ -3764,6 +4444,7 @@ function chatAgent(options) {
3764
4444
  queue.push({
3765
4445
  uiMessage: lastUIMessage,
3766
4446
  modelMessages: modelMsgs,
4447
+ seqNum: record.seqNum,
3767
4448
  });
3768
4449
  locals_js_1.locals.set(chatSteeringQueueKey, queue);
3769
4450
  }
@@ -3771,11 +4452,8 @@ function chatAgent(options) {
3771
4452
  /* conversion failed — skip steering queue */
3772
4453
  }
3773
4454
  }
3774
- return; // Don't add to wire buffer — frontend handles non-injected case
3775
- }
3776
- // No pendingMessages config — standard wire buffer for next turn
3777
- pendingWireMessages.push(msg);
3778
- });
4455
+ })
4456
+ : undefined;
3779
4457
  turnMsgSub = msgSub;
3780
4458
  // Track new messages for this turn (user input + assistant response).
3781
4459
  const turnNewModelMessages = [];
@@ -4097,7 +4775,7 @@ function chatAgent(options) {
4097
4775
  // The turn counter is decremented so the next iteration
4098
4776
  // sees the same `turn` value — actions don't count.
4099
4777
  if (isAction) {
4100
- msgSub.off();
4778
+ msgSub?.off();
4101
4779
  if ((locals_js_1.locals.get(chatPipeCountKey) ?? 0) === 0 &&
4102
4780
  isUIMessageStreamable(actionStreamResult)) {
4103
4781
  try {
@@ -4354,7 +5032,7 @@ function chatAgent(options) {
4354
5032
  }
4355
5033
  }
4356
5034
  finally {
4357
- msgSub.off();
5035
+ msgSub?.off();
4358
5036
  }
4359
5037
  // Wait for onFinish to fire — on abort this may resolve slightly
4360
5038
  // after pipeChat, since the stream's cancel() handler is async.
@@ -4798,7 +5476,7 @@ function chatAgent(options) {
4798
5476
  if (!hydrateMessages) {
4799
5477
  try {
4800
5478
  await tracer_js_1.tracer.startActiveSpan("snapshot.write", async () => {
4801
- const snapshotInCursor = getChatSession().in.lastDispatchedSeqNum();
5479
+ const snapshotInCursor = chatInputRouter().resumeFloor();
4802
5480
  await writeChatSnapshot(sessionIdForSnapshot, {
4803
5481
  version: 1,
4804
5482
  savedAt: Date.now(),
@@ -4837,13 +5515,6 @@ function chatAgent(options) {
4837
5515
  currentWirePayload = bootInjectedQueue.shift();
4838
5516
  return "continue";
4839
5517
  }
4840
- // If messages arrived during streaming (without pendingMessages config),
4841
- // dispatch the oldest as the next turn. The rest stay queued
4842
- // and drain one per turn.
4843
- if (pendingWireMessages.length > 0) {
4844
- currentWirePayload = pendingWireMessages.shift();
4845
- return "continue";
4846
- }
4847
5518
  // chat.requestUpgrade() was called — exit the loop so the
4848
5519
  // transport triggers a new run on the latest version.
4849
5520
  // chat.endRun() — same exit, no upgrade semantics.
@@ -5077,7 +5748,7 @@ function chatAgent(options) {
5077
5748
  // neither the snapshot nor the replayable `.in` tail.
5078
5749
  if (!hydrateMessages) {
5079
5750
  try {
5080
- const errorSnapshotInCursor = getChatSession().in.lastDispatchedSeqNum();
5751
+ const errorSnapshotInCursor = chatInputRouter().resumeFloor();
5081
5752
  await writeChatSnapshot(sessionIdForSnapshot, {
5082
5753
  version: 1,
5083
5754
  savedAt: Date.now(),
@@ -5104,11 +5775,6 @@ function chatAgent(options) {
5104
5775
  currentWirePayload = bootInjectedQueue.shift();
5105
5776
  continue;
5106
5777
  }
5107
- // Same for messages buffered during the errored turn — already consumed, idling strands them.
5108
- if (pendingWireMessages.length > 0) {
5109
- currentWirePayload = pendingWireMessages.shift();
5110
- continue;
5111
- }
5112
5778
  // Wait for the next message — same as after a successful turn
5113
5779
  const effectiveIdleTimeout = metadata_js_1.metadata.get(IDLE_TIMEOUT_METADATA_KEY) ??
5114
5780
  idleTimeoutInSeconds;
@@ -5168,6 +5834,8 @@ function createChatBuilder(config) {
5168
5834
  return createChatBuilder({
5169
5835
  ...config,
5170
5836
  clientDataSchema: cdConfig.schema,
5837
+ clientDataReportErrorAt: cdConfig.reportErrorAt ?? config.clientDataReportErrorAt,
5838
+ clientDataOnValidationError: cdConfig.onValidationError ?? config.clientDataOnValidationError,
5171
5839
  });
5172
5840
  },
5173
5841
  onBoot(fn) {
@@ -5248,6 +5916,10 @@ function createChatBuilder(config) {
5248
5916
  return chatCustomAgent({
5249
5917
  ...options,
5250
5918
  ...(config.clientDataSchema ? { clientDataSchema: config.clientDataSchema } : {}),
5919
+ ...(config.clientDataReportErrorAt
5920
+ ? { clientDataReportErrorAt: config.clientDataReportErrorAt }
5921
+ : {}),
5922
+ onClientDataValidationError: composeHooks(config.clientDataOnValidationError, options.onClientDataValidationError),
5251
5923
  });
5252
5924
  },
5253
5925
  };
@@ -5300,6 +5972,8 @@ function withUIMessage(config) {
5300
5972
  function withClientData(config) {
5301
5973
  return createChatBuilder({
5302
5974
  clientDataSchema: config.schema,
5975
+ clientDataReportErrorAt: config.reportErrorAt,
5976
+ clientDataOnValidationError: config.onValidationError,
5303
5977
  hooks: {},
5304
5978
  });
5305
5979
  }
@@ -5490,6 +6164,58 @@ function isStopped() {
5490
6164
  function requestUpgrade() {
5491
6165
  locals_js_1.locals.set(chatUpgradeRequestedKey, true);
5492
6166
  }
6167
+ /**
6168
+ * Hand off the current custom agent Session to a fresh run.
6169
+ *
6170
+ * This is the low-level handoff for a fully hand-rolled
6171
+ * `chat.customAgent()` loop. This method rejects while a
6172
+ * `chat.createSession()` iterator is active. Close the iterator before calling
6173
+ * it. If `return()` races an active `next()`, it waits for that read to settle
6174
+ * before releasing the handoff guard. Call only between turns and after
6175
+ * detaching input listeners for the old run. If the old run completed its
6176
+ * current turn, persist its state and call {@link chatWriteTurnComplete} before
6177
+ * handing off.
6178
+ * Do not write a new turn boundary after input that the continuation run should
6179
+ * process has been dispatched: the boundary acknowledges that input.
6180
+ *
6181
+ * The server starts the continuation run but does not stop this run, so return
6182
+ * from the task immediately after awaiting this function. The promise rejects
6183
+ * if the server cannot complete the handoff.
6184
+ *
6185
+ * Pending Session input that the old run has not consumed remains on the
6186
+ * durable `.in` stream and is delivered to the continuation run.
6187
+ *
6188
+ * @example
6189
+ * ```ts
6190
+ * // Detach any chat.messages.on() subscriptions you created.
6191
+ * await persistMessages();
6192
+ * await chat.writeTurnComplete();
6193
+ * await chat.endAndContinue();
6194
+ * return;
6195
+ * ```
6196
+ */
6197
+ async function endAndContinue() {
6198
+ if (locals_js_1.locals.get(chatCustomAgentRunKey) !== true) {
6199
+ throw new Error("chat.endAndContinue() can only be called from inside a chat.customAgent() run");
6200
+ }
6201
+ if ((locals_js_1.locals.get(chatActiveSessionIteratorsKey) ?? 0) > 0) {
6202
+ throw new Error("chat.endAndContinue() cannot be called while a chat.createSession() iterator is active. Close the iterator, then call chat.endAndContinue().");
6203
+ }
6204
+ await performEndAndContinue();
6205
+ }
6206
+ /** @internal Shared server handoff used by managed and custom agent loops. */
6207
+ async function performEndAndContinue() {
6208
+ const chatId = locals_js_1.locals.get(chatExternalIdKey);
6209
+ const callingRunId = locals_js_1.locals.get(chatAgentRunContextKey)?.run.id;
6210
+ if (!chatId || !callingRunId) {
6211
+ throw new Error("Cannot end and continue without an active chat agent run");
6212
+ }
6213
+ const apiClient = v3_1.apiClientManager.clientOrThrow();
6214
+ await apiClient.endAndContinueSession(chatId, {
6215
+ callingRunId,
6216
+ reason: "upgrade",
6217
+ });
6218
+ }
5493
6219
  /**
5494
6220
  * Exit the run after the current turn completes, without waiting for the
5495
6221
  * next message. Unlike {@link requestUpgrade}, no upgrade-required signal
@@ -5695,10 +6421,14 @@ function createStopSignal() {
5695
6421
  * task instead of round-tripping them back from the client:
5696
6422
  * - `lastEventId` — the turn-complete control record's seq_num on
5697
6423
  * `session.out`; where the next turn's output stream resumes.
5698
- * - `sessionInEventId` — the committed-consume cursor on `session.in` as of
5699
- * this turn-complete, letting a raw loop correlate the boundary with the
5700
- * exact input record it acknowledged. Trigger owns input-cursor recovery,
5701
- * so this is for correlation / out-of-sync detection, not required.
6424
+ * - `sessionInEventId` — the safe-to-resume-from cursor on `session.in` as of
6425
+ * this turn-complete. It is the highest sequence that can be resumed past
6426
+ * without skipping an unhandled message, so it is held back behind any
6427
+ * message still buffered unconsumed and is NOT necessarily the sequence of
6428
+ * the record this turn answered. Trigger owns input-cursor recovery, so this
6429
+ * is for correlation / out-of-sync detection, not required. Treat it as a
6430
+ * lower bound: a value below the record you just handled is expected, not a
6431
+ * sign of a lost turn.
5702
6432
  *
5703
6433
  * Either is `undefined` when the corresponding cursor isn't available.
5704
6434
  *
@@ -5710,15 +6440,36 @@ function createStopSignal() {
5710
6440
  * ```
5711
6441
  */
5712
6442
  async function chatWriteTurnComplete(options) {
6443
+ await flushDeferredChatCustomAgentClientDataErrors();
5713
6444
  const result = await writeTurnCompleteChunk(undefined, options?.publicAccessToken);
5714
6445
  // Same cursor written to the `session-in-event-id` header inside
5715
6446
  // `writeTurnCompleteChunk`; surfaced here so the caller can persist it.
5716
- const inCursor = getChatSession().in.lastDispatchedSeqNum();
6447
+ const inCursor = chatInputRouter().resumeFloor();
5717
6448
  return {
5718
6449
  lastEventId: result?.lastEventId,
5719
6450
  ...(inCursor !== undefined ? { sessionInEventId: String(inCursor) } : {}),
5720
6451
  };
5721
6452
  }
6453
+ /**
6454
+ * Writes the client-data errors held back by the default `"turn-end"` timing.
6455
+ *
6456
+ * Ordered before the turn-complete chunk, matching the error-then-turn-complete
6457
+ * shape the submitted-turn and async-read paths already write.
6458
+ */
6459
+ async function flushDeferredChatCustomAgentClientDataErrors() {
6460
+ const deferred = locals_js_1.locals.get(chatCustomAgentDeferredClientDataErrorsKey);
6461
+ if (!deferred || deferred.length === 0)
6462
+ return;
6463
+ locals_js_1.locals.set(chatCustomAgentDeferredClientDataErrorsKey, []);
6464
+ for (const payload of deferred) {
6465
+ try {
6466
+ await writeChatCustomAgentClientDataErrorToStream(payload);
6467
+ }
6468
+ catch {
6469
+ /* non-fatal */
6470
+ }
6471
+ }
6472
+ }
5722
6473
  /**
5723
6474
  * Pass every chunk through untouched while recording it in `buffer`. Handles
5724
6475
  * both the `AsyncIterable` and `ReadableStream` shapes `toUIMessageStream()`
@@ -6105,6 +6856,95 @@ class ChatMessageAccumulator {
6105
6856
  return true;
6106
6857
  }
6107
6858
  }
6859
+ function trackActiveChatSessionIterator(iterator) {
6860
+ locals_js_1.locals.set(chatActiveSessionIteratorsKey, (locals_js_1.locals.get(chatActiveSessionIteratorsKey) ?? 0) + 1);
6861
+ let active = true;
6862
+ let closing = false;
6863
+ let activeNextCalls = 0;
6864
+ let closePromise;
6865
+ const nextSettledWaiters = new Set();
6866
+ function finish() {
6867
+ if (!active)
6868
+ return;
6869
+ active = false;
6870
+ const remaining = Math.max((locals_js_1.locals.get(chatActiveSessionIteratorsKey) ?? 1) - 1, 0);
6871
+ locals_js_1.locals.set(chatActiveSessionIteratorsKey, remaining);
6872
+ }
6873
+ function settleNextCall() {
6874
+ activeNextCalls = Math.max(activeNextCalls - 1, 0);
6875
+ if (activeNextCalls > 0)
6876
+ return;
6877
+ for (const resolve of nextSettledWaiters) {
6878
+ resolve();
6879
+ }
6880
+ nextSettledWaiters.clear();
6881
+ }
6882
+ function waitForNextCalls() {
6883
+ if (activeNextCalls === 0)
6884
+ return Promise.resolve();
6885
+ return new Promise((resolve) => nextSettledWaiters.add(resolve));
6886
+ }
6887
+ function closeIterator() {
6888
+ closing = true;
6889
+ if (!closePromise) {
6890
+ closePromise = (async () => {
6891
+ // A blocked next() can install a new message listener after it resumes.
6892
+ // Let every started call settle, then make the inner cleanup final.
6893
+ await waitForNextCalls();
6894
+ try {
6895
+ return iterator.return
6896
+ ? await iterator.return()
6897
+ : { done: true, value: undefined };
6898
+ }
6899
+ finally {
6900
+ finish();
6901
+ }
6902
+ })();
6903
+ }
6904
+ return closePromise;
6905
+ }
6906
+ return {
6907
+ async next() {
6908
+ if (closing) {
6909
+ return { done: true, value: undefined };
6910
+ }
6911
+ activeNextCalls++;
6912
+ let result;
6913
+ try {
6914
+ result = await iterator.next();
6915
+ }
6916
+ catch (error) {
6917
+ settleNextCall();
6918
+ try {
6919
+ await closeIterator();
6920
+ }
6921
+ catch {
6922
+ // Preserve the original iterator error after best-effort cleanup.
6923
+ }
6924
+ throw error;
6925
+ }
6926
+ settleNextCall();
6927
+ if (result.done) {
6928
+ try {
6929
+ await closeIterator();
6930
+ }
6931
+ catch {
6932
+ // The inner next() already ended cleanly; cleanup remains best-effort.
6933
+ }
6934
+ }
6935
+ else if (closing) {
6936
+ // return() won the race. Do not expose a turn the caller has already
6937
+ // abandoned; without a new turn-complete boundary its input remains
6938
+ // replayable by the continuation run.
6939
+ return { done: true, value: undefined };
6940
+ }
6941
+ return result;
6942
+ },
6943
+ return() {
6944
+ return closeIterator();
6945
+ },
6946
+ };
6947
+ }
6108
6948
  /**
6109
6949
  * Create a chat session that yields turns as an async iterator.
6110
6950
  *
@@ -6156,20 +6996,18 @@ function createChatSession(payload, options) {
6156
6996
  const accumulator = new ChatMessageAccumulator();
6157
6997
  let previousTurnUsage;
6158
6998
  let cumulativeUsage = emptyUsage();
6159
- // Messages consumed mid-turn, dispatched one per next(). Iterator-level
6160
- // for the same reason as the agent loop's `pendingWireMessages`:
6161
- // consumed records never replay, so a turn-local buffer loses them.
6162
- const sessionPendingWire = [];
6163
6999
  // The current turn's message subscription — detached defensively at the
6164
7000
  // top of next() in case user code threw without complete()/done().
6165
7001
  let activeMsgSub;
6166
- return {
7002
+ const iterator = {
6167
7003
  async next() {
6168
7004
  activeMsgSub?.off();
6169
7005
  activeMsgSub = undefined;
6170
7006
  if (!booted) {
6171
7007
  booted = true;
6172
- await seedSessionInResumeCursorForCustomLoop(currentPayload);
7008
+ await installChatInputRouter(currentPayload.chatId, {
7009
+ resuming: Boolean(currentPayload.continuation),
7010
+ });
6173
7011
  stop = createStopSignal();
6174
7012
  }
6175
7013
  turn++;
@@ -6226,29 +7064,32 @@ function createChatSession(payload, options) {
6226
7064
  currentPayload = { ...currentPayload, continuation: true };
6227
7065
  }
6228
7066
  }
6229
- // Subsequent turns: drain buffered mid-turn messages first (they
6230
- // were consumed and won't be re-delivered), then wait.
7067
+ /**
7068
+ * Subsequent turns take the next message from the router. A record
7069
+ * that arrived mid-turn is already queued there, so this returns it
7070
+ * without suspending.
7071
+ */
6231
7072
  if (turn > 0) {
6232
- if (sessionPendingWire.length > 0) {
6233
- currentPayload = sessionPendingWire.shift();
7073
+ // chat.requestUpgrade() / chat.endRun() — exit before waiting
7074
+ if (locals_js_1.locals.get(chatUpgradeRequestedKey) || locals_js_1.locals.get(chatEndRunRequestedKey)) {
7075
+ stop.cleanup();
7076
+ return { done: true, value: undefined };
6234
7077
  }
6235
- else {
6236
- // chat.requestUpgrade() / chat.endRun() — exit before waiting
6237
- if (locals_js_1.locals.get(chatUpgradeRequestedKey) || locals_js_1.locals.get(chatEndRunRequestedKey)) {
6238
- stop.cleanup();
6239
- return { done: true, value: undefined };
6240
- }
6241
- const next = await messagesInput.waitWithIdleTimeout({
6242
- idleTimeoutInSeconds,
6243
- timeout,
6244
- spanName: "waiting for next message",
6245
- });
6246
- if (!next.ok || runSignal.aborted) {
6247
- stop.cleanup();
6248
- return { done: true, value: undefined };
6249
- }
6250
- currentPayload = next.output;
7078
+ const next = await messagesInput.waitWithIdleTimeout({
7079
+ idleTimeoutInSeconds,
7080
+ timeout,
7081
+ spanName: "waiting for next message",
7082
+ });
7083
+ if (!next.ok || runSignal.aborted) {
7084
+ stop.cleanup();
7085
+ return { done: true, value: undefined };
6251
7086
  }
7087
+ /**
7088
+ * The facade validated `clientData` against the configured schema
7089
+ * before returning, so the parsed shape is what arrives here. The
7090
+ * cast carries that across the untyped wire payload boundary.
7091
+ */
7092
+ currentPayload = next.output;
6252
7093
  }
6253
7094
  // Check limits
6254
7095
  if (turn >= maxTurns || runSignal.aborted) {
@@ -6272,39 +7113,49 @@ function createChatSession(payload, options) {
6272
7113
  continuation: currentPayload.continuation ?? false,
6273
7114
  clientData: currentPayload.metadata,
6274
7115
  });
6275
- // Listen for messages during streaming (steering + next-turn buffer)
6276
- const sessionMsgSub = messagesInput.on(async (msg) => {
6277
- if (sessionPendingMessages) {
6278
- // Steering route the frontend re-sends non-injected
6279
- // messages on turn complete, so don't also buffer the wire.
6280
- // Slim wire: at most one delta message per record. Read
6281
- // `msg.message` directly — no array slicing needed.
6282
- const lastUIMessage = msg.message;
6283
- if (lastUIMessage) {
6284
- if (sessionPendingMessages.onReceived) {
6285
- try {
6286
- await sessionPendingMessages.onReceived({
6287
- message: lastUIMessage,
6288
- chatId: currentPayload.chatId,
6289
- turn,
6290
- });
6291
- }
6292
- catch {
6293
- /* non-fatal */
6294
- }
6295
- }
7116
+ /**
7117
+ * Only attached when there is a steering config to feed. Without one a
7118
+ * mid-turn message stays queued on the router, which is what keeps the
7119
+ * resume floor behind it.
7120
+ */
7121
+ const handleSteeringMessage = async (msg, seqNum, isActive = () => true) => {
7122
+ const lastUIMessage = msg.message;
7123
+ if (lastUIMessage) {
7124
+ if (sessionPendingMessages?.onReceived) {
6296
7125
  try {
6297
- const modelMsgs = await toModelMessages([lastUIMessage]);
6298
- turnSteeringQueue.push({ uiMessage: lastUIMessage, modelMessages: modelMsgs });
7126
+ await sessionPendingMessages.onReceived({
7127
+ message: lastUIMessage,
7128
+ chatId: currentPayload.chatId,
7129
+ turn,
7130
+ });
6299
7131
  }
6300
7132
  catch {
6301
7133
  /* non-fatal */
6302
7134
  }
6303
7135
  }
6304
- return;
7136
+ if (!isActive())
7137
+ return;
7138
+ try {
7139
+ const modelMsgs = await toModelMessages([lastUIMessage]);
7140
+ turnSteeringQueue.push({
7141
+ uiMessage: lastUIMessage,
7142
+ modelMessages: modelMsgs,
7143
+ seqNum,
7144
+ });
7145
+ }
7146
+ catch {
7147
+ /* non-fatal */
7148
+ }
6305
7149
  }
6306
- sessionPendingWire.push(msg);
6307
- });
7150
+ };
7151
+ const sessionMsgSub = sessionPendingMessages
7152
+ ? locals_js_1.locals.get(chatCustomAgentClientDataParserKey)
7153
+ ? observeValidatedChatMessages((msg, seqNum, isActive) => handleSteeringMessage(msg, seqNum, isActive))
7154
+ : chatInputRouter().observe(CHAT_ROUTE_MESSAGES, (record) => {
7155
+ const msg = record.data.payload;
7156
+ void handleSteeringMessage(msg, record.seqNum);
7157
+ })
7158
+ : undefined;
6308
7159
  activeMsgSub = sessionMsgSub;
6309
7160
  // Accumulate messages. Slim wire: pass the single delta message as
6310
7161
  // a 0-or-1-length array. The accumulator's behavior is unchanged —
@@ -6326,7 +7177,7 @@ function createChatSession(payload, options) {
6326
7177
  // chat.requestUpgrade() called before this turn — signal transport and exit
6327
7178
  if (locals_js_1.locals.get(chatUpgradeRequestedKey)) {
6328
7179
  await writeUpgradeRequiredChunk();
6329
- sessionMsgSub.off();
7180
+ sessionMsgSub?.off();
6330
7181
  stop.cleanup();
6331
7182
  return { done: true, value: undefined };
6332
7183
  }
@@ -6366,7 +7217,7 @@ function createChatSession(payload, options) {
6366
7217
  if (!response || response.role !== "assistant") {
6367
7218
  throw new Error("turn.complete() could not find the spliced handover response");
6368
7219
  }
6369
- sessionMsgSub.off();
7220
+ sessionMsgSub?.off();
6370
7221
  await chatWriteTurnComplete();
6371
7222
  return response;
6372
7223
  }
@@ -6383,7 +7234,7 @@ function createChatSession(payload, options) {
6383
7234
  });
6384
7235
  if (runSignal.aborted) {
6385
7236
  // Full cancel — don't accumulate
6386
- sessionMsgSub.off();
7237
+ sessionMsgSub?.off();
6387
7238
  await chatWriteTurnComplete();
6388
7239
  return undefined;
6389
7240
  }
@@ -6406,7 +7257,7 @@ function createChatSession(payload, options) {
6406
7257
  finally {
6407
7258
  // Detach at stream end (like the agent loop): the steering queue
6408
7259
  // can't inject anymore, so later arrivals must buffer for the next turn.
6409
- sessionMsgSub.off();
7260
+ sessionMsgSub?.off();
6410
7261
  }
6411
7262
  if (response) {
6412
7263
  const cleaned = stop.signal.aborted && !runSignal.aborted
@@ -6502,7 +7353,7 @@ function createChatSession(payload, options) {
6502
7353
  }
6503
7354
  }
6504
7355
  }
6505
- sessionMsgSub.off();
7356
+ sessionMsgSub?.off();
6506
7357
  await chatWriteTurnComplete();
6507
7358
  return response;
6508
7359
  },
@@ -6519,7 +7370,7 @@ function createChatSession(payload, options) {
6519
7370
  await accumulator.addResponse(response);
6520
7371
  },
6521
7372
  async done() {
6522
- sessionMsgSub.off();
7373
+ sessionMsgSub?.off();
6523
7374
  await chatWriteTurnComplete();
6524
7375
  },
6525
7376
  prepareStep() {
@@ -6558,6 +7409,7 @@ function createChatSession(payload, options) {
6558
7409
  return { done: true, value: undefined };
6559
7410
  },
6560
7411
  };
7412
+ return trackActiveChatSessionIterator(iterator);
6561
7413
  },
6562
7414
  };
6563
7415
  }
@@ -7006,6 +7858,8 @@ exports.chat = {
7006
7858
  isStopped,
7007
7859
  /** Request that the run exits after the current turn so the next message starts on the latest version. See {@link requestUpgrade}. */
7008
7860
  requestUpgrade,
7861
+ /** Hand off a custom agent Session to a fresh run. See {@link endAndContinue}. */
7862
+ endAndContinue,
7009
7863
  /** Exit the run after the current turn completes, without any upgrade signal. See {@link endRun}. */
7010
7864
  endRun,
7011
7865
  /** Clean up aborted parts from a UIMessage. See {@link cleanupAbortedParts}. */
@@ -7113,6 +7967,16 @@ exports.chat = {
7113
7967
  */
7114
7968
  async function writeTurnCompleteChunk(_chatId, publicAccessToken) {
7115
7969
  const session = getChatSession();
7970
+ // A handover-prepare boot claims the handover kinds so a signal arriving
7971
+ // before `waitForHandover` attaches is not drained. Released here rather than
7972
+ // only in `waitForHandover`, because a loop that never calls it would
7973
+ // otherwise hold the claim for the life of the run and leave a handover
7974
+ // record parked at the head of the channel, where it wedges
7975
+ // `chat.messages.next()`. Every surface reaches a turn boundary through this
7976
+ // function, including the managed agent, which does not call the public
7977
+ // `chat.writeTurnComplete`. By the time a turn completes the handover window
7978
+ // is over either way.
7979
+ chatInputRouter().clearRoute(CHAT_ROUTE_HANDOVER);
7116
7980
  // 1. Write the turn-complete control record. The ack's `lastEventId` is
7117
7981
  // this record's seq_num — that's the trim target for the NEXT turn.
7118
7982
  //
@@ -7127,10 +7991,15 @@ async function writeTurnCompleteChunk(_chatId, publicAccessToken) {
7127
7991
  if (publicAccessToken) {
7128
7992
  extraHeaders.push(["public-access-token", publicAccessToken]);
7129
7993
  }
7130
- const inCursor = session.in.lastDispatchedSeqNum();
7994
+ const routerCheckpoint = chatInputRouter().checkpoint();
7995
+ const inCursor = routerCheckpoint.resumeFrom;
7131
7996
  if (inCursor !== undefined) {
7132
7997
  extraHeaders.push([v3_1.SESSION_IN_EVENT_ID_HEADER, String(inCursor)]);
7133
7998
  }
7999
+ const consumedCursor = routerCheckpoint.appliedThrough;
8000
+ if (consumedCursor !== undefined) {
8001
+ extraHeaders.push([v3_1.SESSION_IN_CONSUMED_ID_HEADER, String(consumedCursor)]);
8002
+ }
7134
8003
  const result = await session.out.writeControl(v3_1.TRIGGER_CONTROL_SUBTYPE.TURN_COMPLETE, extraHeaders);
7135
8004
  const T_N = result.lastEventId ? Number.parseInt(result.lastEventId, 10) : undefined;
7136
8005
  // 2. Trim back to the previous turn-complete, if we have one. Skipping on
@@ -7189,16 +8058,11 @@ async function writeTurnCompleteChunk(_chatId, publicAccessToken) {
7189
8058
  * @internal
7190
8059
  */
7191
8060
  async function writeUpgradeRequiredChunk() {
7192
- const ctx = v3_1.taskContext.ctx;
7193
- const chatId = ctx?.run.id ? getChatIdFromContext() : undefined;
7194
- const callingRunId = ctx?.run.id;
8061
+ const chatId = locals_js_1.locals.get(chatExternalIdKey);
8062
+ const callingRunId = locals_js_1.locals.get(chatAgentRunContextKey)?.run.id;
7195
8063
  if (chatId && callingRunId) {
7196
- const apiClient = v3_1.apiClientManager.clientOrThrow();
7197
8064
  try {
7198
- await apiClient.endAndContinueSession(chatId, {
7199
- callingRunId,
7200
- reason: "upgrade",
7201
- });
8065
+ await performEndAndContinue();
7202
8066
  }
7203
8067
  catch (error) {
7204
8068
  // Non-fatal: the next `.in/append` re-triggers via the probe.
@@ -7213,16 +8077,6 @@ async function writeUpgradeRequiredChunk() {
7213
8077
  const session = getChatSession();
7214
8078
  return session.out.writeControl(v3_1.TRIGGER_CONTROL_SUBTYPE.UPGRADE_REQUIRED);
7215
8079
  }
7216
- /**
7217
- * Resolves the current chat's `chatId` (used as session externalId) from
7218
- * the bound session handle. Returns `undefined` if no agent is bound —
7219
- * shouldn't happen at the call sites that invoke
7220
- * `writeUpgradeRequiredChunk`, but defensive against misuse.
7221
- * @internal
7222
- */
7223
- function getChatIdFromContext() {
7224
- return locals_js_1.locals.get(chatSessionHandleKey)?.id;
7225
- }
7226
8080
  /**
7227
8081
  * Extracts the text content of the last user message from a UIMessage array.
7228
8082
  * Returns undefined if no user message is found.