@trigger.dev/sdk 0.0.0-prerelease-20260827143549 → 0.0.0-prerelease-20260828141059

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.
@@ -984,6 +984,132 @@ async function withChatWriter(fn) {
984
984
  await flush();
985
985
  return result;
986
986
  }
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
+ }
987
1113
  /**
988
1114
  * Read one record from a route, suspending the run if nothing is there yet.
989
1115
  *
@@ -1002,14 +1128,14 @@ async function waitOnChatRoute(route, options) {
1002
1128
  const warm = await router.next(route, { timeoutMs: idleMs });
1003
1129
  if (warm) {
1004
1130
  span.setAttribute("wait.resolved", "idle");
1005
- return { ok: true, output: warm.data };
1131
+ return { ok: true, output: warm.data, record: warm };
1006
1132
  }
1007
1133
  }
1008
1134
  else {
1009
1135
  const buffered = await router.next(route, { timeoutMs: 0 });
1010
1136
  if (buffered) {
1011
1137
  span.setAttribute("wait.resolved", "buffered");
1012
- return { ok: true, output: buffered.data };
1138
+ return { ok: true, output: buffered.data, record: buffered };
1013
1139
  }
1014
1140
  }
1015
1141
  if (options.skipSuspend) {
@@ -1045,7 +1171,7 @@ async function waitOnChatRoute(route, options) {
1045
1171
  continue;
1046
1172
  if (options.onResume)
1047
1173
  await options.onResume();
1048
- return { ok: true, output: record.data };
1174
+ return { ok: true, output: record.data, record };
1049
1175
  }
1050
1176
  }, {
1051
1177
  attributes: {
@@ -1060,36 +1186,236 @@ async function waitOnChatRoute(route, options) {
1060
1186
  },
1061
1187
  });
1062
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
+ }
1063
1360
  const messagesInput = {
1064
1361
  id: "chat-messages",
1065
1362
  on(handler) {
1066
- return chatInputRouter().on(CHAT_ROUTE_MESSAGES, (record) => {
1067
- const chunk = record.data;
1068
- void Promise.resolve(handler(chunk.payload)).catch(() => { });
1069
- });
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 });
1070
1368
  },
1071
1369
  once(options) {
1072
1370
  return new v3_1.InputStreamOncePromise((resolve, reject) => {
1073
- chatInputRouter()
1074
- .next(CHAT_ROUTE_MESSAGES, { timeoutMs: options?.timeoutMs })
1075
- .then((record) => {
1076
- if (!record) {
1077
- resolve({
1078
- ok: false,
1079
- error: new v3_1.InputStreamTimeoutError("chat-messages", options?.timeoutMs ?? 0),
1080
- });
1081
- return;
1082
- }
1083
- const chunk = record.data;
1084
- resolve({ ok: true, output: chunk.payload });
1085
- }, reject);
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
+ });
1388
+ return;
1389
+ }
1390
+ const claim = observerClaimFor(record);
1391
+ if (claim) {
1392
+ chatInputRouter().untake(CHAT_ROUTE_MESSAGES, record);
1393
+ await claim;
1394
+ take();
1395
+ return;
1396
+ }
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();
1086
1411
  });
1087
1412
  },
1088
1413
  peek() {
1089
1414
  const record = chatInputRouter().peek(CHAT_ROUTE_MESSAGES);
1090
1415
  if (!record)
1091
1416
  return undefined;
1092
- return record.data.payload;
1417
+ const payload = record.data.payload;
1418
+ return validateChatCustomAgentPayloadSync(payload);
1093
1419
  },
1094
1420
  async hasPending() {
1095
1421
  return chatInputRouter().hasPending(CHAT_ROUTE_MESSAGES);
@@ -1100,21 +1426,67 @@ const messagesInput = {
1100
1426
  (!Number.isFinite(timeoutInSeconds) || timeoutInSeconds < 0)) {
1101
1427
  throw new TypeError("chat.messages.next() timeoutInSeconds must be a finite non-negative number");
1102
1428
  }
1103
- const record = await chatInputRouter().next(CHAT_ROUTE_MESSAGES, {
1104
- timeoutMs: timeoutInSeconds === undefined ? undefined : timeoutInSeconds * 1000,
1105
- });
1106
- if (!record)
1107
- return undefined;
1108
- const chunk = record.data;
1109
- return { id: record.id, seqNum: record.seqNum, payload: chunk.payload };
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
+ }
1110
1461
  },
1111
1462
  wait(options) {
1112
1463
  return new v3_1.ManualWaitpointPromise(async (resolve, reject) => {
1113
1464
  try {
1114
- const result = await waitOnChatRoute(CHAT_ROUTE_MESSAGES, { timeout: options?.timeout, spanName: options?.spanName });
1115
- resolve(result.ok
1116
- ? { ok: true, output: result.output.payload }
1117
- : { ok: false, error: result.error ?? new Error("Timed out") });
1465
+ // Same skip-and-wait rule as `waitWithIdleTimeout`, looping back into
1466
+ // the suspending wait rather than surfacing an unvalidated payload.
1467
+ while (true) {
1468
+ const result = await waitOnChatRoute(CHAT_ROUTE_MESSAGES, { timeout: options?.timeout, spanName: options?.spanName });
1469
+ if (!result.ok) {
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 });
1482
+ return;
1483
+ }
1484
+ const validated = await validateChatCustomAgentPayload(payload);
1485
+ if (validated.ok) {
1486
+ resolve({ ok: true, output: validated.payload });
1487
+ return;
1488
+ }
1489
+ }
1118
1490
  }
1119
1491
  catch (error) {
1120
1492
  reject(error);
@@ -1122,10 +1494,31 @@ const messagesInput = {
1122
1494
  });
1123
1495
  },
1124
1496
  async waitWithIdleTimeout(options) {
1125
- const result = await waitOnChatRoute(CHAT_ROUTE_MESSAGES, options);
1126
- return result.ok
1127
- ? { ok: true, output: result.output.payload }
1128
- : { ok: false, error: result.error };
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
+ */
1503
+ while (true) {
1504
+ const result = await waitOnChatRoute(CHAT_ROUTE_MESSAGES, options);
1505
+ if (!result.ok)
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 };
1520
+ }
1521
+ }
1129
1522
  },
1130
1523
  async send(_runId, data, options) {
1131
1524
  await getChatSession().in.send({ kind: "message", payload: data }, options?.requestOptions);
@@ -2029,6 +2422,30 @@ const chatCompactionStateKey = locals_js_1.locals.create("chat.compaction");
2029
2422
  const chatOnCompactedKey = locals_js_1.locals.create("chat.onCompacted");
2030
2423
  /** @internal Full task `ctx` for the active `chat.agent` run (for hooks invoked from nested compaction). */
2031
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");
2032
2449
  const chatPrepareMessagesKey = locals_js_1.locals.create("chat.prepareMessages");
2033
2450
  /**
2034
2451
  * @internal The raw `tools` option from `chat.agent({ tools })`, either a
@@ -2984,7 +3401,11 @@ async function pipeChat(source, options) {
2984
3401
  await waitUntilComplete();
2985
3402
  }
2986
3403
  function chatCustomAgent(options) {
2987
- 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;
2988
3409
  const task = (0, shared_js_1.createTask)({
2989
3410
  ...restOptions,
2990
3411
  triggerSource: "agent",
@@ -3003,6 +3424,17 @@ function chatCustomAgent(options) {
3003
3424
  locals_js_1.locals.set(chatSessionHandleKey, sessions_js_1.sessions.open(payload.chatId));
3004
3425
  locals_js_1.locals.set(chatExternalIdKey, payload.chatId);
3005
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");
3006
3438
  // Initialize the turn-complete trim slot so `chat.writeTurnComplete`
3007
3439
  // trims `session.out` back to the previous turn boundary. Without
3008
3440
  // this the slot is undefined and the trim never runs, so `.out`
@@ -3014,7 +3446,62 @@ function chatCustomAgent(options) {
3014
3446
  await installChatInputRouter(payload.chatId, {
3015
3447
  resuming: Boolean(payload.continuation),
3016
3448
  });
3017
- return userRun(payload, runOptions);
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);
3018
3505
  },
3019
3506
  });
3020
3507
  // Register clientDataSchema so the CLI converts it to JSONSchema
@@ -3044,6 +3531,7 @@ function chatAgent(options) {
3044
3531
  agentConfig: { type: "ai-sdk-chat" },
3045
3532
  run: async (payload, { signal: runSignal, ctx }) => {
3046
3533
  locals_js_1.locals.set(chatAgentRunContextKey, ctx);
3534
+ locals_js_1.locals.set(chatCustomAgentRunKey, false);
3047
3535
  // On AI SDK 7, register the `@ai-sdk/otel` integration (once per process)
3048
3536
  // so `experimental_telemetry` spans flow into the run trace. Awaited here
3049
3537
  // at run boot — before any `streamText` — and a no-op on v5/v6 or when the
@@ -3336,14 +3824,23 @@ function chatAgent(options) {
3336
3824
  hookBeforeBoot = hookResult.beforeBoot;
3337
3825
  }
3338
3826
  }
3339
- // Default: splice partial + the user it was answering into
3340
- // 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.
3341
3838
  let seedChain;
3342
3839
  let recoveredTurns;
3343
3840
  if (hookChain !== undefined) {
3344
3841
  seedChain = hookChain;
3345
3842
  }
3346
- else if (partialAssistant !== undefined && inFlightUsers.length > 0) {
3843
+ else if (partialAssistant !== undefined && inFlightUsers.length > 1) {
3347
3844
  seedChain = [...settledMessages, inFlightUsers[0], partialAssistant];
3348
3845
  }
3349
3846
  else {
@@ -3352,7 +3849,7 @@ function chatAgent(options) {
3352
3849
  if (hookRecoveredTurns !== undefined) {
3353
3850
  recoveredTurns = hookRecoveredTurns;
3354
3851
  }
3355
- else if (partialAssistant !== undefined && inFlightUsers.length > 0) {
3852
+ else if (partialAssistant !== undefined && inFlightUsers.length > 1) {
3356
3853
  recoveredTurns = inFlightUsers.slice(1);
3357
3854
  }
3358
3855
  else {
@@ -5337,6 +5834,8 @@ function createChatBuilder(config) {
5337
5834
  return createChatBuilder({
5338
5835
  ...config,
5339
5836
  clientDataSchema: cdConfig.schema,
5837
+ clientDataReportErrorAt: cdConfig.reportErrorAt ?? config.clientDataReportErrorAt,
5838
+ clientDataOnValidationError: cdConfig.onValidationError ?? config.clientDataOnValidationError,
5340
5839
  });
5341
5840
  },
5342
5841
  onBoot(fn) {
@@ -5417,6 +5916,10 @@ function createChatBuilder(config) {
5417
5916
  return chatCustomAgent({
5418
5917
  ...options,
5419
5918
  ...(config.clientDataSchema ? { clientDataSchema: config.clientDataSchema } : {}),
5919
+ ...(config.clientDataReportErrorAt
5920
+ ? { clientDataReportErrorAt: config.clientDataReportErrorAt }
5921
+ : {}),
5922
+ onClientDataValidationError: composeHooks(config.clientDataOnValidationError, options.onClientDataValidationError),
5420
5923
  });
5421
5924
  },
5422
5925
  };
@@ -5469,6 +5972,8 @@ function withUIMessage(config) {
5469
5972
  function withClientData(config) {
5470
5973
  return createChatBuilder({
5471
5974
  clientDataSchema: config.schema,
5975
+ clientDataReportErrorAt: config.reportErrorAt,
5976
+ clientDataOnValidationError: config.onValidationError,
5472
5977
  hooks: {},
5473
5978
  });
5474
5979
  }
@@ -5659,6 +6164,58 @@ function isStopped() {
5659
6164
  function requestUpgrade() {
5660
6165
  locals_js_1.locals.set(chatUpgradeRequestedKey, true);
5661
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
+ }
5662
6219
  /**
5663
6220
  * Exit the run after the current turn completes, without waiting for the
5664
6221
  * next message. Unlike {@link requestUpgrade}, no upgrade-required signal
@@ -5883,6 +6440,7 @@ function createStopSignal() {
5883
6440
  * ```
5884
6441
  */
5885
6442
  async function chatWriteTurnComplete(options) {
6443
+ await flushDeferredChatCustomAgentClientDataErrors();
5886
6444
  const result = await writeTurnCompleteChunk(undefined, options?.publicAccessToken);
5887
6445
  // Same cursor written to the `session-in-event-id` header inside
5888
6446
  // `writeTurnCompleteChunk`; surfaced here so the caller can persist it.
@@ -5892,6 +6450,26 @@ async function chatWriteTurnComplete(options) {
5892
6450
  ...(inCursor !== undefined ? { sessionInEventId: String(inCursor) } : {}),
5893
6451
  };
5894
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
+ }
5895
6473
  /**
5896
6474
  * Pass every chunk through untouched while recording it in `buffer`. Handles
5897
6475
  * both the `AsyncIterable` and `ReadableStream` shapes `toUIMessageStream()`
@@ -6278,6 +6856,95 @@ class ChatMessageAccumulator {
6278
6856
  return true;
6279
6857
  }
6280
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
+ }
6281
6948
  /**
6282
6949
  * Create a chat session that yields turns as an async iterator.
6283
6950
  *
@@ -6332,7 +6999,7 @@ function createChatSession(payload, options) {
6332
6999
  // The current turn's message subscription — detached defensively at the
6333
7000
  // top of next() in case user code threw without complete()/done().
6334
7001
  let activeMsgSub;
6335
- return {
7002
+ const iterator = {
6336
7003
  async next() {
6337
7004
  activeMsgSub?.off();
6338
7005
  activeMsgSub = undefined;
@@ -6417,6 +7084,11 @@ function createChatSession(payload, options) {
6417
7084
  stop.cleanup();
6418
7085
  return { done: true, value: undefined };
6419
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
+ */
6420
7092
  currentPayload = next.output;
6421
7093
  }
6422
7094
  // Check limits
@@ -6446,36 +7118,43 @@ function createChatSession(payload, options) {
6446
7118
  * mid-turn message stays queued on the router, which is what keeps the
6447
7119
  * resume floor behind it.
6448
7120
  */
6449
- const sessionMsgSub = sessionPendingMessages
6450
- ? chatInputRouter().observe(CHAT_ROUTE_MESSAGES, async (record) => {
6451
- const msg = record.data.payload;
6452
- const lastUIMessage = msg.message;
6453
- if (lastUIMessage) {
6454
- if (sessionPendingMessages.onReceived) {
6455
- try {
6456
- await sessionPendingMessages.onReceived({
6457
- message: lastUIMessage,
6458
- chatId: currentPayload.chatId,
6459
- turn,
6460
- });
6461
- }
6462
- catch {
6463
- /* non-fatal */
6464
- }
6465
- }
7121
+ const handleSteeringMessage = async (msg, seqNum, isActive = () => true) => {
7122
+ const lastUIMessage = msg.message;
7123
+ if (lastUIMessage) {
7124
+ if (sessionPendingMessages?.onReceived) {
6466
7125
  try {
6467
- const modelMsgs = await toModelMessages([lastUIMessage]);
6468
- turnSteeringQueue.push({
6469
- uiMessage: lastUIMessage,
6470
- modelMessages: modelMsgs,
6471
- seqNum: record.seqNum,
7126
+ await sessionPendingMessages.onReceived({
7127
+ message: lastUIMessage,
7128
+ chatId: currentPayload.chatId,
7129
+ turn,
6472
7130
  });
6473
7131
  }
6474
7132
  catch {
6475
7133
  /* non-fatal */
6476
7134
  }
6477
7135
  }
6478
- })
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
+ }
7149
+ }
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
+ })
6479
7158
  : undefined;
6480
7159
  activeMsgSub = sessionMsgSub;
6481
7160
  // Accumulate messages. Slim wire: pass the single delta message as
@@ -6730,6 +7409,7 @@ function createChatSession(payload, options) {
6730
7409
  return { done: true, value: undefined };
6731
7410
  },
6732
7411
  };
7412
+ return trackActiveChatSessionIterator(iterator);
6733
7413
  },
6734
7414
  };
6735
7415
  }
@@ -7178,6 +7858,8 @@ exports.chat = {
7178
7858
  isStopped,
7179
7859
  /** Request that the run exits after the current turn so the next message starts on the latest version. See {@link requestUpgrade}. */
7180
7860
  requestUpgrade,
7861
+ /** Hand off a custom agent Session to a fresh run. See {@link endAndContinue}. */
7862
+ endAndContinue,
7181
7863
  /** Exit the run after the current turn completes, without any upgrade signal. See {@link endRun}. */
7182
7864
  endRun,
7183
7865
  /** Clean up aborted parts from a UIMessage. See {@link cleanupAbortedParts}. */
@@ -7376,16 +8058,11 @@ async function writeTurnCompleteChunk(_chatId, publicAccessToken) {
7376
8058
  * @internal
7377
8059
  */
7378
8060
  async function writeUpgradeRequiredChunk() {
7379
- const ctx = v3_1.taskContext.ctx;
7380
- const chatId = ctx?.run.id ? getChatIdFromContext() : undefined;
7381
- 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;
7382
8063
  if (chatId && callingRunId) {
7383
- const apiClient = v3_1.apiClientManager.clientOrThrow();
7384
8064
  try {
7385
- await apiClient.endAndContinueSession(chatId, {
7386
- callingRunId,
7387
- reason: "upgrade",
7388
- });
8065
+ await performEndAndContinue();
7389
8066
  }
7390
8067
  catch (error) {
7391
8068
  // Non-fatal: the next `.in/append` re-triggers via the probe.
@@ -7400,16 +8077,6 @@ async function writeUpgradeRequiredChunk() {
7400
8077
  const session = getChatSession();
7401
8078
  return session.out.writeControl(v3_1.TRIGGER_CONTROL_SUBTYPE.UPGRADE_REQUIRED);
7402
8079
  }
7403
- /**
7404
- * Resolves the current chat's `chatId` (used as session externalId) from
7405
- * the bound session handle. Returns `undefined` if no agent is bound —
7406
- * shouldn't happen at the call sites that invoke
7407
- * `writeUpgradeRequiredChunk`, but defensive against misuse.
7408
- * @internal
7409
- */
7410
- function getChatIdFromContext() {
7411
- return locals_js_1.locals.get(chatSessionHandleKey)?.id;
7412
- }
7413
8080
  /**
7414
8081
  * Extracts the text content of the last user message from a UIMessage array.
7415
8082
  * Returns undefined if no user message is found.