@trigger.dev/sdk 0.0.0-prerelease-20260908122921 → 0.0.0-prerelease-streamfix-20260909094302

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 (106) hide show
  1. package/dist/commonjs/imports/ai-runtime-cjs.cjs.map +1 -1
  2. package/dist/commonjs/imports/ai-runtime.js +0 -2
  3. package/dist/commonjs/v3/ai.d.ts +16 -199
  4. package/dist/commonjs/v3/ai.js +102 -983
  5. package/dist/commonjs/v3/ai.js.map +1 -1
  6. package/dist/commonjs/v3/chat-client.d.ts +2 -3
  7. package/dist/commonjs/v3/chat-client.js +5 -31
  8. package/dist/commonjs/v3/chat-client.js.map +1 -1
  9. package/dist/commonjs/v3/chat-react.d.ts +0 -34
  10. package/dist/commonjs/v3/chat-react.js +1 -47
  11. package/dist/commonjs/v3/chat-react.js.map +1 -1
  12. package/dist/commonjs/v3/chat-server.d.ts +6 -42
  13. package/dist/commonjs/v3/chat-server.js +7 -52
  14. package/dist/commonjs/v3/chat-server.js.map +1 -1
  15. package/dist/commonjs/v3/chat.d.ts +10 -81
  16. package/dist/commonjs/v3/chat.js +46 -292
  17. package/dist/commonjs/v3/chat.js.map +1 -1
  18. package/dist/commonjs/v3/sessions.d.ts +2 -15
  19. package/dist/commonjs/v3/sessions.js +1 -12
  20. package/dist/commonjs/v3/sessions.js.map +1 -1
  21. package/dist/commonjs/v3/shared.js +36 -30
  22. package/dist/commonjs/v3/shared.js.map +1 -1
  23. package/dist/commonjs/v3/test/mock-chat-agent.d.ts +0 -43
  24. package/dist/commonjs/v3/test/mock-chat-agent.js +0 -90
  25. package/dist/commonjs/v3/test/mock-chat-agent.js.map +1 -1
  26. package/dist/commonjs/v3/test/test-session-handle.js +0 -6
  27. package/dist/commonjs/v3/test/test-session-handle.js.map +1 -1
  28. package/dist/commonjs/version.js +1 -1
  29. package/dist/esm/imports/ai-runtime.d.ts +2 -2
  30. package/dist/esm/imports/ai-runtime.js +2 -2
  31. package/dist/esm/imports/ai-runtime.js.map +1 -1
  32. package/dist/esm/v3/ai.d.ts +16 -199
  33. package/dist/esm/v3/ai.js +103 -984
  34. package/dist/esm/v3/ai.js.map +1 -1
  35. package/dist/esm/v3/chat-client.d.ts +2 -3
  36. package/dist/esm/v3/chat-client.js +5 -31
  37. package/dist/esm/v3/chat-client.js.map +1 -1
  38. package/dist/esm/v3/chat-react.d.ts +0 -34
  39. package/dist/esm/v3/chat-react.js +1 -46
  40. package/dist/esm/v3/chat-react.js.map +1 -1
  41. package/dist/esm/v3/chat-server.d.ts +6 -42
  42. package/dist/esm/v3/chat-server.js +8 -53
  43. package/dist/esm/v3/chat-server.js.map +1 -1
  44. package/dist/esm/v3/chat.d.ts +10 -81
  45. package/dist/esm/v3/chat.js +47 -293
  46. package/dist/esm/v3/chat.js.map +1 -1
  47. package/dist/esm/v3/sessions.d.ts +2 -15
  48. package/dist/esm/v3/sessions.js +1 -11
  49. package/dist/esm/v3/sessions.js.map +1 -1
  50. package/dist/esm/v3/shared.js +23 -17
  51. package/dist/esm/v3/shared.js.map +1 -1
  52. package/dist/esm/v3/test/mock-chat-agent.d.ts +0 -43
  53. package/dist/esm/v3/test/mock-chat-agent.js +2 -92
  54. package/dist/esm/v3/test/mock-chat-agent.js.map +1 -1
  55. package/dist/esm/v3/test/test-session-handle.js +0 -6
  56. package/dist/esm/v3/test/test-session-handle.js.map +1 -1
  57. package/dist/esm/version.js +1 -1
  58. package/docs/ai-chat/actions.mdx +23 -55
  59. package/docs/ai-chat/anatomy.mdx +3 -3
  60. package/docs/ai-chat/backend.mdx +48 -125
  61. package/docs/ai-chat/background-injection.mdx +19 -67
  62. package/docs/ai-chat/client-protocol.mdx +4 -5
  63. package/docs/ai-chat/compaction.mdx +7 -11
  64. package/docs/ai-chat/custom-agents.mdx +0 -23
  65. package/docs/ai-chat/fast-starts.mdx +20 -27
  66. package/docs/ai-chat/frontend.mdx +14 -17
  67. package/docs/ai-chat/migrating-from-a-route-handler.mdx +14 -16
  68. package/docs/ai-chat/patterns/skills.mdx +10 -7
  69. package/docs/ai-chat/patterns/version-upgrades.mdx +6 -79
  70. package/docs/ai-chat/pending-messages.mdx +3 -3
  71. package/docs/ai-chat/prompt-caching.mdx +25 -23
  72. package/docs/ai-chat/quick-start.mdx +11 -11
  73. package/docs/ai-chat/reference.mdx +5 -12
  74. package/docs/ai-chat/sessions.mdx +1 -6
  75. package/docs/ai-chat/testing.mdx +1 -2
  76. package/docs/ai-chat/tools.mdx +13 -18
  77. package/docs/ai-chat/upgrade-guide.mdx +2 -2
  78. package/docs/apikeys.mdx +45 -27
  79. package/docs/deployment/overview.mdx +8 -4
  80. package/docs/deployment/preview-branches.mdx +4 -4
  81. package/docs/deployment/version-skew-protection.mdx +0 -62
  82. package/docs/manual-setup.mdx +7 -7
  83. package/docs/mcp-tools.mdx +0 -9
  84. package/docs/quick-start.mdx +3 -3
  85. package/docs/realtime/auth.mdx +1 -1
  86. package/docs/self-hosting/security.mdx +0 -5
  87. package/docs/tasks/scheduled.mdx +0 -24
  88. package/docs/triggering.mdx +1 -1
  89. package/package.json +4 -4
  90. package/skills/trigger-authoring-chat-agent/SKILL.md +27 -38
  91. package/skills/trigger-chat-agent-advanced/SKILL.md +12 -31
  92. package/dist/commonjs/v3/chatVersionSkew.d.ts +0 -12
  93. package/dist/commonjs/v3/chatVersionSkew.js +0 -30
  94. package/dist/commonjs/v3/chatVersionSkew.js.map +0 -1
  95. package/dist/commonjs/v3/externalDeploymentId.d.ts +0 -23
  96. package/dist/commonjs/v3/externalDeploymentId.js +0 -43
  97. package/dist/commonjs/v3/externalDeploymentId.js.map +0 -1
  98. package/dist/esm/v3/chatVersionSkew.d.ts +0 -12
  99. package/dist/esm/v3/chatVersionSkew.js +0 -27
  100. package/dist/esm/v3/chatVersionSkew.js.map +0 -1
  101. package/dist/esm/v3/externalDeploymentId.d.ts +0 -23
  102. package/dist/esm/v3/externalDeploymentId.js +0 -38
  103. package/dist/esm/v3/externalDeploymentId.js.map +0 -1
  104. package/docs/ai-chat/patterns/native-compaction.mdx +0 -310
  105. package/docs/reports.mdx +0 -157
  106. package/docs/troubleshooting-zod.mdx +0 -158
package/dist/esm/v3/ai.js CHANGED
@@ -1,8 +1,8 @@
1
- import { accessoryAttributes, apiClientManager, controlSubtype, generateJWT, getSchemaParseFn, headerValue, InputStreamOncePromise, isAdditionalApiKey, isSchemaZodEsque, logger, ManualWaitpointPromise, OutOfMemoryError, resourceCatalog, SemanticInternalAttributes, SESSION_IN_CONSUMED_ID_HEADER, SESSION_IN_EVENT_ID_HEADER, sessionStreams, SessionChannelRouter, InputStreamTimeoutError, taskContext, TRIGGER_CONTROL_SUBTYPE, SESSION_CLOSED_HEADER, SESSION_CLOSED_REASON_HEADER, tryCatch, } from "@trigger.dev/core/v3";
1
+ import { accessoryAttributes, apiClientManager, controlSubtype, generateJWT, getSchemaParseFn, headerValue, InputStreamOncePromise, isAdditionalApiKey, isSchemaZodEsque, logger, ManualWaitpointPromise, OutOfMemoryError, resourceCatalog, SemanticInternalAttributes, SESSION_IN_CONSUMED_ID_HEADER, SESSION_IN_EVENT_ID_HEADER, sessionStreams, SessionChannelRouter, InputStreamTimeoutError, taskContext, TRIGGER_CONTROL_SUBTYPE, } from "@trigger.dev/core/v3";
2
2
  // Runtime VALUES go through the ESM/CJS shim so the CJS build can `require`
3
3
  // ESM-only `ai@7` (see ../imports/ai-runtime.ts).
4
4
  import { trace } from "@opentelemetry/api";
5
- import { tool as aiTool, convertToModelMessages, dynamicTool, generateId as generateMessageId, getToolName, isToolUIPart, jsonSchema, readUIMessageStream, streamText as aiStreamText, zodSchema, } from "../imports/ai-runtime.js";
5
+ import { tool as aiTool, convertToModelMessages, dynamicTool, generateId as generateMessageId, getToolName, isToolUIPart, jsonSchema, readUIMessageStream, zodSchema, } from "../imports/ai-runtime.js";
6
6
  import { PENDING_MESSAGE_INJECTED_TYPE, upsertIncomingMessage, chatRunTags, } from "./ai-shared.js";
7
7
  import { auth } from "./auth.js";
8
8
  import { locals } from "./locals.js";
@@ -17,8 +17,6 @@ import { metadata } from "./metadata.js";
17
17
  // pulled in transitively here never reach a client chunk.
18
18
  import { readFileInSkill, runBashInSkill } from "./agentSkillsRuntime.js";
19
19
  import { ensureAiSdkTelemetry } from "./aiAutoTelemetry.js";
20
- import { withResolvedExternalDeploymentId } from "./externalDeploymentId.js";
21
- import { resolvePinToFollow } from "./chatVersionSkew.js";
22
20
  import { sessions, } from "./sessions.js";
23
21
  import { createTask } from "./shared.js";
24
22
  import { markChatAgentRunForStreamsWarning } from "./streams.js";
@@ -1756,13 +1754,6 @@ async function installChatInputRouter(chatId, options) {
1756
1754
  checkpoint.appliedThrough = Math.max(checkpoint.appliedThrough ?? replayWindowEnd, replayWindowEnd);
1757
1755
  }
1758
1756
  }
1759
- // A boot that replayed `.in` itself has already answered everything up to
1760
- // `recoveredThrough`, so the floor has to cover it before the tail opens.
1761
- if (options?.recoveredThrough !== undefined) {
1762
- const recovered = options.recoveredThrough;
1763
- checkpoint.resumeFrom = Math.max(checkpoint.resumeFrom ?? recovered, recovered);
1764
- checkpoint.appliedThrough = Math.max(checkpoint.appliedThrough ?? checkpoint.resumeFrom, checkpoint.resumeFrom);
1765
- }
1766
1757
  const router = entry.router;
1767
1758
  router.restore(checkpoint);
1768
1759
  const floor = router.resumeFrom();
@@ -1771,10 +1762,6 @@ async function installChatInputRouter(chatId, options) {
1771
1762
  sessionStreams.setLastDispatchedSeqNum(chatId, "in", floor);
1772
1763
  }
1773
1764
  sessionStreams.onRecord(chatId, "in", (record) => {
1774
- // The floor is the tail's `Last-Event-ID`, but a reconnect can still
1775
- // re-deliver below it and a replayable route would re-queue it.
1776
- if (floor !== undefined && record.seqNum <= floor)
1777
- return true;
1778
1765
  router.ingest(record);
1779
1766
  return true;
1780
1767
  });
@@ -1946,29 +1933,6 @@ function spliceHandoverPartial(modelMessages, uiMessages, signal) {
1946
1933
  * @internal
1947
1934
  */
1948
1935
  const chatBackgroundQueueKey = locals.create("chat.backgroundQueue");
1949
- /**
1950
- * System-role context injected mid-conversation, held for the instructions lane.
1951
- *
1952
- * Kept apart from the message queue because ai@7 rejects a system message inside
1953
- * `messages` for every provider — `standardizePrompt` throws upstream of any
1954
- * provider call, and its own advice is to use the instructions option. Instructions
1955
- * accept `Array<SystemModelMessage>`, so a system-role injection has a correct
1956
- * home: appended as another system block rather than smuggled into the transcript.
1957
- *
1958
- * This is also the only way to inject *trusted* context. A message injected as
1959
- * `user` is untrusted by construction, and a well-aligned model treats it that
1960
- * way — it will say so, and re-derive the answer from tools instead.
1961
- */
1962
- const chatInjectedInstructionsKey = locals.create("chat.injectedInstructions");
1963
- /**
1964
- * What a turn already consumed from the instructions lane, so a second
1965
- * `toStreamTextOptions()` call in the same turn sees the same blocks.
1966
- *
1967
- * Consumed blocks are moved here rather than left in the pending lane: leaving
1968
- * them there means an injection made during the consumed turn sits behind them,
1969
- * and clearing the lane on the next turn destroys both.
1970
- */
1971
- const chatInstructionsConsumedKey = locals.create("chat.injectedInstructionsConsumed");
1972
1936
  /**
1973
1937
  * Run-scoped pipe counter. Stored in locals so concurrent runs in the
1974
1938
  * same worker don't share state.
@@ -2479,31 +2443,12 @@ const chatToolsOptionKey = locals.create("chat.toolsOption");
2479
2443
  const chatResolvedToolsKey = locals.create("chat.resolvedTools");
2480
2444
  /** @internal Flag set by `chat.requestUpgrade()` to exit the loop after the current turn. */
2481
2445
  const chatUpgradeRequestedKey = locals.create("chat.upgradeRequested");
2482
- /** @internal Target for the upgrade handoff, set by `chat.requestUpgrade({ externalDeploymentId })`. */
2483
- const chatUpgradeExternalDeploymentIdKey = locals.create("chat.upgradeExternalDeploymentId");
2484
2446
  /**
2485
2447
  * @internal Flag set by `chat.endRun()` to exit the loop after the current
2486
2448
  * turn completes, without any upgrade semantics. Checked at the same
2487
2449
  * post-turn / pre-wait sites as `chatUpgradeRequestedKey`.
2488
2450
  */
2489
2451
  const chatEndRunRequestedKey = locals.create("chat.endRunRequested");
2490
- /**
2491
- * @internal Set by `chat.close()`. Holds the close request (and its reason)
2492
- * for the rest of the run: the loop writes the terminal `session-closed`
2493
- * record, closes the session row, and exits at the same post-turn /
2494
- * pre-wait sites as `chatEndRunRequestedKey`.
2495
- */
2496
- const chatCloseRequestedKey = locals.create("chat.closeRequested");
2497
- /** @internal Matches the server's `CloseSessionRequestBody.reason` cap. */
2498
- const CHAT_CLOSE_REASON_MAX_LENGTH = 256;
2499
- /** @internal Set once the session row is closed, so the close happens once. */
2500
- const chatClosePerformedKey = locals.create("chat.closePerformed");
2501
- /**
2502
- * @internal Set once the terminal `.out` record is written. Tracked apart from
2503
- * {@link chatClosePerformedKey} so a retried close does not emit a second
2504
- * client-visible event.
2505
- */
2506
- const chatCloseRecordWrittenKey = locals.create("chat.closeRecordWritten");
2507
2452
  /** @internal */
2508
2453
  const chatAgentCompactionKey = locals.create("chat.agentCompaction");
2509
2454
  /**
@@ -2520,32 +2465,6 @@ export { PENDING_MESSAGE_INJECTED_TYPE, upsertIncomingMessage };
2520
2465
  const chatPendingMessagesKey = locals.create("chat.pendingMessages");
2521
2466
  /** @internal */
2522
2467
  const chatSteeringQueueKey = locals.create("chat.steeringQueue");
2523
- /**
2524
- * This turn's new messages, as `onTurnComplete.newUIMessages` will see them.
2525
- *
2526
- * Held in locals because `drainSteeringQueue` runs outside the turn closure and
2527
- * has to append the messages it injects. Without that, an injected message
2528
- * reaches the model and the browser but no hook, so an app persisting from
2529
- * `onTurnComplete` never learns it existed.
2530
- */
2531
- const chatTurnNewUIMessagesKey = locals.create("chat.turnNewUIMessages");
2532
- /**
2533
- * Steering messages a drain consumed that the model accumulator has not been
2534
- * given yet.
2535
- *
2536
- * The two accumulators are maintained separately, and the model one is
2537
- * normally advanced by appending each turn's delta. A drained message is
2538
- * appended to the UI one but reaches the model only through the `prepareStep`
2539
- * return value, which is per-step: without this the model lane never learns
2540
- * the message exists and every later turn of the run answers without it,
2541
- * while the browser, the snapshot and `chat.history.*` all still show it.
2542
- *
2543
- * Held as the messages rather than a "rebuild me" flag because the model lane
2544
- * can only be appended to, never reconstructed. Compaction replaces it with a
2545
- * summary and deliberately leaves the UI lane whole, so reconverting the UI
2546
- * lane restores every message the summary replaced.
2547
- */
2548
- const chatPendingSteerKey = locals.create("chat.pendingSteer");
2549
2468
  /** @internal — IDs of messages that were successfully injected via prepareStep */
2550
2469
  const chatInjectedMessageIdsKey = locals.create("chat.injectedMessageIds");
2551
2470
  /** @internal — non-transient data parts queued via chat.response or writer.write() for accumulation into the response message */
@@ -2898,32 +2817,20 @@ function chatCompactionStep(options) {
2898
2817
  return result.type === "skipped" ? undefined : result;
2899
2818
  };
2900
2819
  }
2901
- const EMPTY_DRAIN = { injected: [], claimed: [] };
2902
- /**
2903
- * The model messages to record for one claimed message. Without `prepare`
2904
- * each entry's own conversion is used. With it, `prepare` returned one list
2905
- * for the whole batch, so the first claimed message carries all of it and the
2906
- * rest carry none, which keeps the total exactly what the model received.
2907
- */
2908
- function modelFormOf(m, batch, injected) {
2909
- return batch[0] === m ? injected : [];
2910
- }
2820
+ // ---------------------------------------------------------------------------
2821
+ // Steering queue drain — shared by toStreamTextOptions, session, accumulator
2822
+ // ---------------------------------------------------------------------------
2911
2823
  /**
2912
2824
  * Drain the steering queue as a batch. Calls `shouldInject` once with all
2913
2825
  * pending messages. If it returns true, calls `prepareMessages` once to
2914
2826
  * transform the batch, then clears the queue.
2915
- * Returns the model messages to inject and the UI messages actually claimed.
2916
- *
2917
- * `claimed` is returned rather than only published to locals because each
2918
- * surface files it somewhere different: `chat.agent` has an accumulator in
2919
- * locals, while `chat.createSession` keeps its own. Publishing to locals alone
2920
- * is silently a no-op for any surface that never set the key.
2827
+ * Returns the model messages to inject (empty if none).
2921
2828
  * @internal
2922
2829
  */
2923
2830
  async function drainSteeringQueue(config, messages, steps, queueOverride) {
2924
2831
  const queue = queueOverride ?? locals.get(chatSteeringQueueKey);
2925
2832
  if (!queue || queue.length === 0)
2926
- return EMPTY_DRAIN;
2833
+ return [];
2927
2834
  const ctx = locals.get(chatTurnContextKey);
2928
2835
  const stepNumber = steps.length - 1;
2929
2836
  /**
@@ -2946,7 +2853,7 @@ async function drainSteeringQueue(config, messages, steps, queueOverride) {
2946
2853
  // Call shouldInject once for the whole batch
2947
2854
  const shouldInject = config.shouldInject ? await config.shouldInject(batchEvent) : false;
2948
2855
  if (!shouldInject)
2949
- return EMPTY_DRAIN;
2856
+ return [];
2950
2857
  const textOfUIMessage = (m) => (m.parts ?? [])
2951
2858
  .filter((p) => p.type === "text")
2952
2859
  .map((p) => p.text)
@@ -2992,7 +2899,7 @@ async function drainSteeringQueue(config, messages, steps, queueOverride) {
2992
2899
  queue.splice(at, 1);
2993
2900
  }
2994
2901
  if (claimed.length === 0)
2995
- return EMPTY_DRAIN;
2902
+ return [];
2996
2903
  /**
2997
2904
  * Give the claim back if the transform fails. `prepare` is caller code and
2998
2905
  * can throw; the records have already left the router by this point, so
@@ -3022,37 +2929,6 @@ async function drainSteeringQueue(config, messages, steps, queueOverride) {
3022
2929
  for (const m of claimedUIMessages)
3023
2930
  injectedIds.add(m.id);
3024
2931
  }
3025
- // Record them as part of the conversation.
3026
- //
3027
- // The model has them and the browser has them; without this the
3028
- // accumulator does not, so they reach neither `uiMessages` nor
3029
- // `newUIMessages` on `onTurnComplete` and an app that persists from there
3030
- // silently loses the instruction the answer was shaped by. Appending here
3031
- // rather than at turn end keeps them in the order they happened: after the
3032
- // message that started the turn, before the response that answers it.
3033
- //
3034
- // De-duplicated by id because a step boundary can drain more than once per
3035
- // turn, and because a message that failed to inject falls back to becoming
3036
- // its own turn, where it is accumulated the normal way.
3037
- const currentUIMessages = locals.get(chatCurrentUIMessagesKey);
3038
- const turnNew = locals.get(chatTurnNewUIMessagesKey);
3039
- for (const m of claimedUIMessages) {
3040
- if (currentUIMessages && !currentUIMessages.some((existing) => existing.id === m.id)) {
3041
- currentUIMessages.push(m);
3042
- }
3043
- if (turnNew && !turnNew.some((existing) => existing.id === m.id)) {
3044
- turnNew.push(m);
3045
- }
3046
- }
3047
- if (claimedUIMessages.length > 0 && currentUIMessages) {
3048
- const pendingSteer = locals.get(chatPendingSteerKey) ?? [];
3049
- for (const m of claimedUIMessages) {
3050
- if (!pendingSteer.some((existing) => existing.ui.id === m.id)) {
3051
- pendingSteer.push({ ui: m, model: modelFormOf(m, claimedUIMessages, injected) });
3052
- }
3053
- }
3054
- locals.set(chatPendingSteerKey, pendingSteer);
3055
- }
3056
2932
  // Write injection confirmation chunk to the stream so the frontend
3057
2933
  // knows which messages were injected and where in the response.
3058
2934
  if (injected.length > 0) {
@@ -3094,7 +2970,7 @@ async function drainSteeringQueue(config, messages, steps, queueOverride) {
3094
2970
  /* non-fatal */
3095
2971
  }
3096
2972
  }
3097
- return { injected, claimed: claimedUIMessages };
2973
+ return injected;
3098
2974
  }, {
3099
2975
  attributes: {
3100
2976
  [SemanticInternalAttributes.STYLE_ICON]: "tabler-message-forward",
@@ -3320,149 +3196,32 @@ export function buildSkillTools(skills) {
3320
3196
  return { loadSkill, readFile, bash };
3321
3197
  }
3322
3198
  /**
3323
- * A `streamText` with the agent's managed options already applied.
3324
- *
3325
- * Handed to `run()` so the managed state cannot be missed by omission. Spreading
3326
- * `chat.toStreamTextOptions()` is still supported and equivalent; this exists
3327
- * because forgetting the spread silently drops the managed prompt, the skill
3328
- * tools, telemetry, and the `prepareStep` that delivers steering, compaction and
3329
- * conversational injection.
3330
- *
3331
- * Caller options win for everything the caller owns (model, messages, signal,
3332
- * stopWhen). The three that would otherwise clobber managed behaviour are
3333
- * merged instead of replaced:
3334
- *
3335
- * - `tools` are passed into the helper, so skill tools survive.
3336
- * - `prepareStep` is composed after the managed one, so a caller's per-step
3337
- * overrides apply on top of steering and compaction instead of disabling them.
3338
- *
3339
- * `system` may be set at the call site, on `chat.agent({ system })`, or
3340
- * through `chat.prompt.set()`, but only in one of them. Setting it in two
3341
- * places throws: no shape merges two system values on every supported
3342
- * version, and dropping one silently is the failure this seam exists to
3343
- * prevent. Injected instructions append to whichever one is in play.
3344
- */
3345
- /**
3346
- * The agent-level managed options (`registry`, `system`, `cacheControl`,
3347
- * `systemProviderOptions`), published for the run so that
3348
- * `chat.toStreamTextOptions()` applies them too. Without this only the bound
3349
- * `streamText` saw them, and the documented spread form silently ran without
3350
- * the agent's system prompt or model.
3351
- */
3352
- const chatAgentManagedConfigKey = locals.create("chat.agentManagedConfig");
3353
- /**
3354
- * The caller's `streamText` options merged with the agent's managed ones.
3199
+ * Returns an options object ready to spread into `streamText()`.
3200
+ *
3201
+ * Includes `system`, `experimental_telemetry`, and any config fields
3202
+ * (temperature, maxTokens, etc.) from the stored prompt.
3355
3203
  *
3356
- * Pure, and separate from the call so it can be asserted directly: everything
3357
- * the caller did not name has to survive the merge, and the way to be sure of
3358
- * that is to look at the merged object rather than at what the model received.
3204
+ * When a `registry` is provided and the prompt has a `model` string,
3205
+ * the resolved `LanguageModel` is included as `model`.
3206
+ *
3207
+ * If no prompt has been set, returns `{}` (no-op spread).
3359
3208
  */
3360
- function buildManagedStreamTextOptions(options, config) {
3361
- const { registry, system: agentSystem, cacheControl, systemProviderOptions, tools: agentTools, } = config;
3362
- /**
3363
- * Only the three keys that collide are intercepted. Everything else, telemetry
3364
- * included, stays in `rest` and reaches `streamText` untouched, with the
3365
- * caller's value winning because `rest` is spread after `managed`. Pulling a
3366
- * key out to "handle" it is how a caller's option gets silently dropped.
3367
- */
3368
- const { tools, system: callerSystem, prepareStep: callerPrepareStep, ...rest } = options;
3369
- const managed = toStreamTextOptions({
3370
- registry,
3371
- system: callerSystem ?? agentSystem,
3372
- cacheControl,
3373
- systemProviderOptions,
3374
- /**
3375
- * A call site that names `tools` replaces the agent's set rather than
3376
- * adding to it, so narrowing the tools for one call still works. Omitting
3377
- * `tools` falls back to the agent's, which is what an `onAction`
3378
- * regenerate needs: without it a regenerated answer can call nothing.
3379
- */
3380
- tools: (tools ?? agentTools),
3381
- });
3382
- const promptSystem = locals.get(chatPromptKey)?.text;
3383
- /**
3384
- * Two managed sources conflict too, not only a caller against a managed one.
3385
- * `toStreamTextOptions` resolves `prompt?.text || agentSystem`, so the prompt
3386
- * would win and `chat.agent({ system })` would go nowhere.
3387
- */
3388
- if (promptSystem && agentSystem) {
3389
- throw new Error("chat.agent: `system` is set both on chat.agent({ system }) and by chat.prompt.set(), and only one " +
3390
- "of them can apply. The prompt would win and the agent's `system` would go nowhere. Keep it in one " +
3391
- "place, and add per-turn context with chat.inject({ role: 'system' }).");
3392
- }
3393
- const managedSystem = promptSystem || agentSystem;
3394
- if (callerSystem !== undefined && managedSystem) {
3395
- throw new Error("chat.agent: `system` is already set " +
3396
- (promptSystem ? "by chat.prompt.set()" : "on chat.agent({ system })") +
3397
- ", so it cannot also be passed to the `streamText` given to run(). Set it in one place, and add " +
3398
- "per-turn context with chat.inject({ role: 'system' }) rather than a second system value.");
3399
- }
3400
- const managedPrepareStep = managed.prepareStep;
3401
- if (typeof callerPrepareStep === "function") {
3402
- managed.prepareStep = async (arg) => {
3403
- const first = managedPrepareStep ? await managedPrepareStep(arg) : undefined;
3404
- const second = await callerPrepareStep({ ...arg, ...(first ?? {}) });
3405
- return { ...(first ?? {}), ...(second ?? {}) };
3406
- };
3407
- }
3408
- return { ...managed, ...rest };
3409
- }
3410
- /** @internal Test hook for {@link buildManagedStreamTextOptions}. */
3411
- export const __buildManagedStreamTextOptionsForTests = buildManagedStreamTextOptions;
3412
- function createBoundStreamText(registry, agentSystem, agentCacheControl, agentSystemProviderOptions) {
3413
- const bound = (options = {}) => aiStreamText(buildManagedStreamTextOptions(options, {
3414
- registry,
3415
- system: agentSystem,
3416
- cacheControl: agentCacheControl,
3417
- systemProviderOptions: agentSystemProviderOptions,
3418
- /** Read per call, so per-turn tools resolved after binding are included. */
3419
- tools: locals.get(chatResolvedToolsKey),
3420
- }));
3421
- return bound;
3422
- }
3423
3209
  function toStreamTextOptions(options) {
3424
- const agentDefaults = locals.get(chatAgentManagedConfigKey);
3425
- if (agentDefaults) {
3426
- options = {
3427
- registry: agentDefaults.registry,
3428
- system: agentDefaults.system,
3429
- cacheControl: agentDefaults.cacheControl,
3430
- systemProviderOptions: agentDefaults.systemProviderOptions,
3431
- ...options,
3432
- };
3433
- }
3434
3210
  const prompt = locals.get(chatPromptKey);
3435
3211
  const skills = locals.get(chatSkillsKey);
3436
3212
  const result = {};
3437
3213
  // Build the combined system prompt: stored prompt + skills preamble.
3438
- const baseSystem = options?.system;
3439
- const baseSystemText = typeof baseSystem === "string"
3440
- ? baseSystem
3441
- : typeof baseSystem?.content === "string"
3442
- ? baseSystem.content
3443
- : "";
3444
- const promptText = prompt?.text || baseSystemText;
3214
+ const promptText = prompt?.text ?? "";
3445
3215
  const skillsText = skills && skills.length > 0 ? buildSkillsSystemPrompt(skills) : "";
3446
3216
  if (promptText || skillsText) {
3447
3217
  const systemText = [promptText, skillsText].filter(Boolean).join("\n\n");
3448
- /**
3449
- * Resolve system-prompt provider options for caching. Precedence, most
3450
- * specific first and no deep merge: explicit `systemProviderOptions`, the
3451
- * `cacheControl` sugar, the ones carried on a structured `system` message,
3452
- * then whatever `chat.prompt.set()` stored.
3453
- *
3454
- * A structured `system` counts only when its own text is the one being
3455
- * sent. When `chat.prompt.set()` supplied the text, its provider options
3456
- * are the ones that describe it.
3457
- */
3458
- const baseSystemProviderOptions = !prompt?.text && baseSystem && typeof baseSystem !== "string"
3459
- ? baseSystem.providerOptions
3460
- : undefined;
3218
+ // Resolve system-prompt provider options for caching. Precedence (most
3219
+ // specific wins, no deep merge): explicit `systemProviderOptions` →
3220
+ // `cacheControl` sugar → `providerOptions` stored on `chat.prompt.set()`.
3461
3221
  const systemProviderOptions = options?.systemProviderOptions ??
3462
3222
  (options?.cacheControl
3463
3223
  ? { anthropic: { cacheControl: options.cacheControl } }
3464
3224
  : undefined) ??
3465
- baseSystemProviderOptions ??
3466
3225
  locals.get(chatPromptProviderOptionsKey);
3467
3226
  // A bare string stays a bare string (the unchanged default). With provider
3468
3227
  // options, emit a structured `SystemModelMessage` so the provider can cache
@@ -3471,88 +3230,6 @@ function toStreamTextOptions(options) {
3471
3230
  ? { role: "system", content: systemText, providerOptions: systemProviderOptions }
3472
3231
  : systemText;
3473
3232
  }
3474
- /**
3475
- * Append anything injected as system context, in whichever shape the installed
3476
- * AI SDK accepts.
3477
- *
3478
- * `system` widened over time: on ai@5 it is `string` only, and from ai@6 it is
3479
- * `string | SystemModelMessage | Array<SystemModelMessage>`. This package's peer
3480
- * range still spans all three, so emitting an array unconditionally would break
3481
- * v5 consumers — for whom a system-role injection used to work, since v5 accepted
3482
- * a system message inside `messages` that v7 rejects.
3483
- *
3484
- * So: concatenate into one string when the base is a plain string, which every
3485
- * version accepts and which loses nothing (separate blocks only matter for
3486
- * per-block `providerOptions`). Use the array form only when the base is already
3487
- * a structured message — that path requires v6+ regardless, because it is how
3488
- * prompt caching marks the system block, and flattening it would silently throw
3489
- * the cache away.
3490
- *
3491
- * Either way the injected text goes last: the base prompt keeps its position for
3492
- * caching, and the addition reads as a later amendment. A changed prefix does
3493
- * cost the first call its cache hit, on turns that actually injected.
3494
- */
3495
- /**
3496
- * Consumed once per turn, not once per read, and moved out of the lane rather
3497
- * than marked read in place.
3498
- *
3499
- * Per turn, because a `run()` that builds options twice (a cheap classifier
3500
- * pass and then the answer) has to see the injection in both, and draining on
3501
- * read hands it to whichever call ran first. Moved out, because blocks left in
3502
- * the lane sit in front of anything injected during the same turn, and
3503
- * clearing the lane on the next turn then destroys both. Outside a turn there
3504
- * is no turn to scope the stash to, so the lane drains on read there.
3505
- */
3506
- const injectedInstructions = locals.get(chatInjectedInstructionsKey);
3507
- const currentTurn = locals.get(chatTurnContextKey)?.turn;
3508
- const consumedThisTurn = currentTurn === undefined ? undefined : locals.get(chatInstructionsConsumedKey);
3509
- let injectedBlocks = [];
3510
- if (consumedThisTurn && consumedThisTurn.turn === currentTurn) {
3511
- injectedBlocks = consumedThisTurn.blocks;
3512
- // Anything injected since the stash was taken joins it, so an instruction
3513
- // added after an action read the lane still reaches the real turn that
3514
- // shares the action's turn number, rather than the one after.
3515
- if (injectedInstructions && injectedInstructions.length > 0) {
3516
- injectedBlocks.push(...injectedInstructions.splice(0));
3517
- }
3518
- }
3519
- else if (injectedInstructions && injectedInstructions.length > 0) {
3520
- injectedBlocks = injectedInstructions.splice(0);
3521
- if (currentTurn !== undefined) {
3522
- locals.set(chatInstructionsConsumedKey, { turn: currentTurn, blocks: injectedBlocks });
3523
- }
3524
- }
3525
- if (injectedBlocks.length > 0) {
3526
- const blocks = injectedBlocks;
3527
- const injectedText = blocks
3528
- .map((block) => (typeof block.content === "string" ? block.content : ""))
3529
- .filter(Boolean)
3530
- .join("\n\n");
3531
- const base = result.system;
3532
- if (base === undefined) {
3533
- result.system = injectedText;
3534
- }
3535
- else if (typeof base === "string") {
3536
- result.system = [base, injectedText].filter(Boolean).join("\n\n");
3537
- }
3538
- else {
3539
- // Merged into the existing block rather than added as a second one. An array
3540
- // of system blocks would keep the base block's cache entry, but ai@5 rejects
3541
- // it outright ("Invalid prompt: system must be a string") while accepting a
3542
- // single structured block, and this package's peer range still spans v5.
3543
- // Choosing per version would mean resolving the installed version at runtime,
3544
- // which is not something to build on: `import.meta.url` is illegal in this
3545
- // package's CommonJS output, and a bundled task may have no resolvable `ai`
3546
- // to read. One shape that works everywhere beats a cache hit.
3547
- const baseBlock = base;
3548
- result.system = {
3549
- ...baseBlock,
3550
- content: [typeof baseBlock.content === "string" ? baseBlock.content : "", injectedText]
3551
- .filter(Boolean)
3552
- .join("\n\n"),
3553
- };
3554
- }
3555
- }
3556
3233
  // Prompt-related options (only if chat.prompt.set() was called)
3557
3234
  if (prompt) {
3558
3235
  // Resolve model via registry if both are present
@@ -3607,7 +3284,7 @@ function toStreamTextOptions(options) {
3607
3284
  }
3608
3285
  // 2. Pending message injection (steering)
3609
3286
  if (taskPendingMessages) {
3610
- const { injected } = await drainSteeringQueue(taskPendingMessages, resultMessages ?? messages, steps);
3287
+ const injected = await drainSteeringQueue(taskPendingMessages, resultMessages ?? messages, steps);
3611
3288
  if (injected.length > 0) {
3612
3289
  resultMessages = [...(resultMessages ?? messages), ...injected];
3613
3290
  }
@@ -3623,61 +3300,6 @@ function toStreamTextOptions(options) {
3623
3300
  }
3624
3301
  return result;
3625
3302
  }
3626
- const actionTurnBrand = Symbol.for("trigger.dev/chat/actionTurn");
3627
- /**
3628
- * Turn the current action into a turn.
3629
- *
3630
- * Return it from `onAction` after editing history. The action's own work is
3631
- * finished first (the edit is applied and snapshotted), then a turn runs on the
3632
- * result exactly as a message turn does: `onTurnStart`, `run()` with the edited
3633
- * history, `onBeforeTurnComplete`, `onTurnComplete`, and the turn counter
3634
- * advances. That gives the answer everything a turn has, the system prompt,
3635
- * tools, steering, compaction, injected instructions and persistence, with no
3636
- * action-specific handling.
3637
- *
3638
- * @example
3639
- * ```ts
3640
- * onAction: async ({ action }) => {
3641
- * if (action.type === "regenerate") {
3642
- * chat.history.slice(0, -1);
3643
- * return chat.turn();
3644
- * }
3645
- * if (action.type === "undo") chat.history.slice(0, -2); // no turn
3646
- * },
3647
- * ```
3648
- */
3649
- function chatTurn() {
3650
- return { [actionTurnBrand]: true };
3651
- }
3652
- function isActionTurn(value) {
3653
- return typeof value === "object" && value !== null && value[actionTurnBrand] === true;
3654
- }
3655
- /**
3656
- * Replace, in a model lane, the run of messages one UI message contributed.
3657
- *
3658
- * Used when a UI message is replaced in place (a tool-approval continuation
3659
- * merging onto the trailing assistant, a captured response reusing an existing
3660
- * id, a partial replacing an existing message). Reconverting the whole lane
3661
- * from the UI lane would also replace a compaction summary with the full
3662
- * transcript and drop the model forms `pendingMessages.prepare` produced.
3663
- *
3664
- * The replaced message is the trailing one, so its run is the lane's tail,
3665
- * before any steer forms appended after it this turn (`tailAfter`). If the
3666
- * tail does not match the old message's conversion, nothing is changed and
3667
- * `false` is returned so the caller can fall back to a full reconversion.
3668
- */
3669
- async function replaceModelRun(lane, oldUi, newUi, tailAfter) {
3670
- const oldRun = await toModelMessages([stripProviderMetadata(oldUi)]);
3671
- const newRun = await toModelMessages([stripProviderMetadata(newUi)]);
3672
- const end = lane.length - tailAfter;
3673
- const start = end - oldRun.length;
3674
- if (start < 0 || end > lane.length)
3675
- return false;
3676
- if (JSON.stringify(lane.slice(start, end)) !== JSON.stringify(oldRun))
3677
- return false;
3678
- lane.splice(start, oldRun.length, ...newRun);
3679
- return true;
3680
- }
3681
3303
  function isUIMessageStreamable(value) {
3682
3304
  return (typeof value === "object" &&
3683
3305
  value !== null &&
@@ -3816,22 +3438,10 @@ function chatCustomAgent(options) {
3816
3438
  await installChatInputRouter(payload.chatId, {
3817
3439
  resuming: Boolean(payload.continuation),
3818
3440
  });
3819
- // A custom agent's loop is the customer's, so there is no exit site the
3820
- // SDK controls. Perform a requested close when `run()` returns, whatever
3821
- // shape the loop had. Idempotent, so the createSession iterator having
3822
- // already closed on its own exit costs nothing.
3823
- const withClose = async (result) => {
3824
- try {
3825
- return await result;
3826
- }
3827
- finally {
3828
- await performChatClose();
3829
- }
3830
- };
3831
3441
  // Keep the schema-free path identical to the original custom-agent
3832
3442
  // wrapper, including when userRun starts executing.
3833
3443
  if (!parseClientData) {
3834
- return withClose(userRun(payload, runOptions));
3444
+ return userRun(payload, runOptions);
3835
3445
  }
3836
3446
  const isHandoverBoot = payload.trigger === "handover-prepare";
3837
3447
  const isMessagelessBoot = payload.trigger === "preload" ||
@@ -3847,7 +3457,7 @@ function chatCustomAgent(options) {
3847
3457
  writeErrorToStream: !isMessagelessBoot && !isHandoverBoot,
3848
3458
  });
3849
3459
  if (validated.ok) {
3850
- return withClose(userRun(validated.payload, runOptions));
3460
+ return userRun(validated.payload, runOptions);
3851
3461
  }
3852
3462
  if (isHandoverBoot) {
3853
3463
  const signal = await waitForHandover({
@@ -3883,7 +3493,7 @@ function chatCustomAgent(options) {
3883
3493
  sessionId: next.output.sessionId ?? payload.sessionId,
3884
3494
  idleTimeoutInSeconds: next.output.idleTimeoutInSeconds ?? payload.idleTimeoutInSeconds,
3885
3495
  };
3886
- return withClose(userRun(recoveredPayload, runOptions));
3496
+ return userRun(recoveredPayload, runOptions);
3887
3497
  },
3888
3498
  });
3889
3499
  // Register clientDataSchema so the CLI converts it to JSONSchema
@@ -3896,7 +3506,7 @@ function chatCustomAgent(options) {
3896
3506
  return task;
3897
3507
  }
3898
3508
  function chatAgent(options) {
3899
- const { run: userRun, clientDataSchema, onBoot, onRecoveryBoot, onPreload, onChatStart, onValidateMessages, hydrateMessages, actionSchema, onAction, onTurnStart, onBeforeTurnComplete, onCompacted, compaction, pendingMessages: pendingMessagesConfig, prepareMessages, tools: toolsOption, onTurnComplete, maxTurns = 100, turnTimeout = "1h", idleTimeoutInSeconds = 30, chatAccessTokenTTL = "1h", preloadIdleTimeoutInSeconds, preloadTimeout, uiMessageStreamOptions, onChatSuspend, onChatResume, exitAfterPreloadIdle = false, oomMachine, registry: promptRegistry, system: agentSystem, cacheControl: agentCacheControl, systemProviderOptions: agentSystemProviderOptions, versionSkew, ...restOptions } = options;
3509
+ const { run: userRun, clientDataSchema, onBoot, onRecoveryBoot, onPreload, onChatStart, onValidateMessages, hydrateMessages, actionSchema, onAction, onTurnStart, onBeforeTurnComplete, onCompacted, compaction, pendingMessages: pendingMessagesConfig, prepareMessages, tools: toolsOption, onTurnComplete, maxTurns = 100, turnTimeout = "1h", idleTimeoutInSeconds = 30, chatAccessTokenTTL = "1h", preloadIdleTimeoutInSeconds, preloadTimeout, uiMessageStreamOptions, onChatSuspend, onChatResume, exitAfterPreloadIdle = false, oomMachine, ...restOptions } = options;
3900
3510
  const parseClientData = clientDataSchema ? getSchemaParseFn(clientDataSchema) : undefined;
3901
3511
  const parseAction = actionSchema ? getSchemaParseFn(actionSchema) : undefined;
3902
3512
  // chat.agent does not expose generic retry options (see docstring on
@@ -3979,24 +3589,6 @@ function chatAgent(options) {
3979
3589
  // durable snapshot + `session.out` replay (or `hydrateMessages` if
3980
3590
  // registered) — the wire is delta-only now, no longer a seed.
3981
3591
  let accumulatedMessages = [];
3982
- /**
3983
- * Give the model accumulator the steering messages a drain consumed,
3984
- * in the form the model actually received. Appended, never reconverted
3985
- * from the UI lane, so a model-only compaction summary survives. Called
3986
- * on both the success and the error path, before the response or the
3987
- * partial joins the lane, so the order stays steer-then-answer.
3988
- */
3989
- const reconcilePendingSteer = (options) => {
3990
- const pending = locals.get(chatPendingSteerKey);
3991
- if (!pending || pending.length === 0)
3992
- return [];
3993
- locals.set(chatPendingSteerKey, []);
3994
- for (const entry of pending) {
3995
- accumulatedMessages.push(...entry.model);
3996
- options?.turnNew?.push(...entry.model);
3997
- }
3998
- return pending;
3999
- };
4000
3592
  // Accumulated UI messages for persistence. Mirrors the model accumulator
4001
3593
  // but in frontend-friendly UIMessage format (with parts, id, etc.).
4002
3594
  let accumulatedUIMessages = [];
@@ -4015,58 +3607,6 @@ function chatAgent(options) {
4015
3607
  // swallow errors internally; the agent stays available either way.
4016
3608
  const sessionIdForSnapshot = payload.sessionId ?? payload.chatId;
4017
3609
  let bootSnapshot;
4018
- /**
4019
- * The `lastOutEventId` the most recent snapshot carried.
4020
- *
4021
- * A snapshot written outside a turn — after an action mutates history — has
4022
- * no turn cursor of its own, and writing `undefined` there would drop the
4023
- * resume point and make the next boot replay from further back. Retaining it
4024
- * keeps an action's write cursor-neutral.
4025
- */
4026
- let lastSnapshotOutEventId;
4027
- /**
4028
- * Persist the accumulator outside a turn.
4029
- *
4030
- * An action is not a turn, so it never reaches the turn-complete path where
4031
- * the snapshot is normally written — but it can change the conversation in
4032
- * two ways: a `chat.history` mutation, and a response streamed back from
4033
- * `onAction`. Both have to survive, and one write at the end of the action
4034
- * covers both rather than writing twice for a regenerate that does both.
4035
- *
4036
- * Cursor-neutral: an action has no turn cursor of its own, and writing
4037
- * `undefined` would drop the resume point the last turn established and make
4038
- * the next boot replay from further back.
4039
- */
4040
- const writeSnapshotOutsideTurn = async (reason) => {
4041
- if (hydrateMessages)
4042
- return;
4043
- try {
4044
- await tracer.startActiveSpan("snapshot.write", async () => {
4045
- const snapshotInCursor = chatInputRouter().resumeFloor();
4046
- await writeChatSnapshot(sessionIdForSnapshot, {
4047
- version: 1,
4048
- savedAt: Date.now(),
4049
- messages: accumulatedUIMessages,
4050
- lastOutEventId: lastSnapshotOutEventId,
4051
- lastInEventId: snapshotInCursor !== undefined ? String(snapshotInCursor) : undefined,
4052
- });
4053
- }, {
4054
- attributes: {
4055
- [SemanticInternalAttributes.STYLE_ICON]: "task-hook-onStart",
4056
- [SemanticInternalAttributes.COLLAPSED]: true,
4057
- "chat.snapshot.reason": reason,
4058
- "chat.messages.count": accumulatedUIMessages.length,
4059
- },
4060
- });
4061
- }
4062
- catch (error) {
4063
- logger.warn("chat.agent: snapshot write outside a turn failed; the change may not survive a continuation", {
4064
- error: error instanceof Error ? error.message : String(error),
4065
- sessionId: sessionIdForSnapshot,
4066
- reason,
4067
- });
4068
- }
4069
- };
4070
3610
  let replayedSettled = [];
4071
3611
  let replayedPartial;
4072
3612
  let replayedPartialRaw;
@@ -4109,7 +3649,6 @@ function chatAgent(options) {
4109
3649
  // Without seeding, the new worker would emit no trim on its first
4110
3650
  // turn (chain self-bootstraps from turn 2), so this is purely an
4111
3651
  // optimization to keep continuation runs bounded from the first turn.
4112
- lastSnapshotOutEventId = bootSnapshot?.lastOutEventId;
4113
3652
  if (bootSnapshot?.lastOutEventId !== undefined) {
4114
3653
  const seeded = Number.parseInt(bootSnapshot.lastOutEventId, 10);
4115
3654
  if (Number.isFinite(seeded)) {
@@ -4204,13 +3743,9 @@ function chatAgent(options) {
4204
3743
  // Reads the turn boundary and subscribes in one call. `bootInCursor` is
4205
3744
  // only a fallback: the boot block above may already have resolved a
4206
3745
  // cursor from the snapshot, which is used when the boundary itself
4207
- // carries none. Everything the boot replayed off `.in` is dispatched from
4208
- // `bootInjectedQueue` below, so it goes into the floor here — folded in
4209
- // after the subscription opens, the live tail re-delivers it as a turn.
4210
- const lastRecoveredInSeq = replayedInTail.length > 0 ? replayedInTail[replayedInTail.length - 1].seqNum : undefined;
3746
+ // carries none.
4211
3747
  await installChatInputRouter(payload.chatId, {
4212
3748
  fallbackResumeFrom: bootInCursorResolved ? bootInCursor : undefined,
4213
- recoveredThrough: lastRecoveredInSeq,
4214
3749
  resuming: Boolean(payload.continuation) || ctx.attempt.number > 1,
4215
3750
  });
4216
3751
  // ── Recovery boot + chain reconstruction ────────────────────────
@@ -4318,6 +3853,16 @@ function chatAgent(options) {
4318
3853
  if (hookBeforeBoot) {
4319
3854
  await hookBeforeBoot();
4320
3855
  }
3856
+ // Advance the session.in cursor past every recovered user so
3857
+ // the live subscription doesn't re-deliver them.
3858
+ if (replayedInTail.length > 0) {
3859
+ const lastRecoveredSeq = replayedInTail[replayedInTail.length - 1].seqNum;
3860
+ const currentCursor = sessionStreams.lastSeqNum(payload.chatId, "in");
3861
+ if (currentCursor === undefined || lastRecoveredSeq > currentCursor) {
3862
+ sessionStreams.setLastSeqNum(payload.chatId, "in", lastRecoveredSeq);
3863
+ sessionStreams.setLastDispatchedSeqNum(payload.chatId, "in", lastRecoveredSeq);
3864
+ }
3865
+ }
4321
3866
  // Synthesize wire payloads for each recoveredTurn. The turn-loop
4322
3867
  // pops these ahead of `messagesInput.waitWithIdleTimeout` so they
4323
3868
  // dispatch as normal turns with the existing hook stack.
@@ -4410,12 +3955,6 @@ function chatAgent(options) {
4410
3955
  // before any hook (`onChatStart`, `onTurnStart`, etc.) fires.
4411
3956
  locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
4412
3957
  }
4413
- locals.set(chatAgentManagedConfigKey, {
4414
- registry: promptRegistry,
4415
- system: agentSystem,
4416
- cacheControl: agentCacheControl,
4417
- systemProviderOptions: agentSystemProviderOptions,
4418
- });
4419
3958
  // Token usage tracking across turns
4420
3959
  let previousTurnUsage;
4421
3960
  let cumulativeUsage = emptyUsage();
@@ -4911,10 +4450,6 @@ function chatAgent(options) {
4911
4450
  // Track new messages for this turn (user input + assistant response).
4912
4451
  const turnNewModelMessages = [];
4913
4452
  const turnNewUIMessages = [];
4914
- locals.set(chatTurnNewUIMessagesKey, turnNewUIMessages);
4915
- // A head-start handover deliberately resumes from an assistant
4916
- // message it spliced in, so it isn't a no-op turn.
4917
- let splicedHandoverPartial = false;
4918
4453
  // ── Action handling ──────────────────────────────────────
4919
4454
  // Actions arrive on the same input stream but with
4920
4455
  // trigger === "action". They are NOT turns — only
@@ -4925,16 +4460,7 @@ function chatAgent(options) {
4925
4460
  // an action, return a `StreamTextResult` (auto-piped),
4926
4461
  // string, or UIMessage from `onAction`. Turn counter
4927
4462
  // does not advance.
4928
- let actionResult = undefined;
4929
- /** Set when `onAction` returned `chat.turn()`: the turn block below runs. */
4930
- let actionTurn = false;
4931
- /**
4932
- * Whether this action changed the conversation, by rolling history
4933
- * back or by streaming a response. Drives the single snapshot write
4934
- * at the end — an action never reaches the turn-complete path that
4935
- * normally does it.
4936
- */
4937
- let actionChangedHistory = false;
4463
+ let actionStreamResult = undefined;
4938
4464
  if (isAction) {
4939
4465
  // Parse and validate the action payload
4940
4466
  const parsedAction = parseAction
@@ -4968,7 +4494,7 @@ function chatAgent(options) {
4968
4494
  // Fire onAction — handler may mutate state via
4969
4495
  // `chat.history.*` and / or return a model response.
4970
4496
  if (onAction) {
4971
- actionResult = await tracer.startActiveSpan("onAction()", async () => {
4497
+ actionStreamResult = await tracer.startActiveSpan("onAction()", async () => {
4972
4498
  return await onAction({
4973
4499
  action: parsedAction,
4974
4500
  chatId: currentWirePayload.chatId,
@@ -4994,7 +4520,6 @@ function chatAgent(options) {
4994
4520
  accumulatedUIMessages = [...actionOverride];
4995
4521
  accumulatedMessages = await toModelMessages(actionOverride);
4996
4522
  locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
4997
- actionChangedHistory = true;
4998
4523
  }
4999
4524
  }
5000
4525
  else {
@@ -5163,7 +4688,6 @@ function chatAgent(options) {
5163
4688
  // where AI SDK regenerates the id (TRI-9137) still
5164
4689
  // applies via `rewriteIncomingIdViaToolCallMap`.
5165
4690
  let replaced = false;
5166
- const replacedPairs = [];
5167
4691
  for (const raw of cleanedUIMessages) {
5168
4692
  let incoming = raw;
5169
4693
  let idx = accumulatedUIMessages.findIndex((m) => m.id === incoming.id);
@@ -5175,9 +4699,7 @@ function chatAgent(options) {
5175
4699
  }
5176
4700
  }
5177
4701
  if (idx !== -1) {
5178
- const previous = accumulatedUIMessages[idx];
5179
- accumulatedUIMessages[idx] = mergeIncomingIntoHydrated(previous, incoming);
5180
- replacedPairs.push({ previous, merged: accumulatedUIMessages[idx] });
4702
+ accumulatedUIMessages[idx] = mergeIncomingIntoHydrated(accumulatedUIMessages[idx], incoming);
5181
4703
  replaced = true;
5182
4704
  }
5183
4705
  else {
@@ -5187,17 +4709,9 @@ function chatAgent(options) {
5187
4709
  recordToolCallIdsFromMessage(incoming);
5188
4710
  }
5189
4711
  if (replaced) {
5190
- let inPlace = true;
5191
- for (const { previous, merged } of replacedPairs) {
5192
- if (!(await replaceModelRun(accumulatedMessages, previous, merged, 0))) {
5193
- inPlace = false;
5194
- break;
5195
- }
5196
- }
5197
- if (!inPlace) {
5198
- logger.warn("chat.agent: replaced message not found at the model lane tail; reconverting the lane");
5199
- accumulatedMessages = await toModelMessages(accumulatedUIMessages);
5200
- }
4712
+ // Replacement changes structure — reconvert all model
4713
+ // messages instead of appending.
4714
+ accumulatedMessages = await toModelMessages(accumulatedUIMessages);
5201
4715
  }
5202
4716
  else {
5203
4717
  const incomingModelMessages = await toModelMessages(cleanedUIMessages);
@@ -5240,33 +4754,10 @@ function chatAgent(options) {
5240
4754
  messageId: locals.get(chatHandoverMessageIdKey),
5241
4755
  });
5242
4756
  locals.set(chatHandoverPartialKey, []); // consume once
5243
- splicedHandoverPartial = true;
5244
4757
  }
5245
4758
  }
5246
4759
  locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
5247
4760
  } // end if (trigger !== "action")
5248
- // ── No-op turn ──────────────────────────────────────────
5249
- //
5250
- // A submit that added no new user message and leaves the model
5251
- // chain ending on an assistant message has nothing to answer —
5252
- // calling the model would prefill its own last reply. Keyed on
5253
- // the model tail, so a `tool`-terminated chain (a merged tool
5254
- // approval) still runs.
5255
- const isNoOpTurn = !isAction &&
5256
- !splicedHandoverPartial &&
5257
- currentWirePayload.trigger === "submit-message" &&
5258
- turnNewUIMessages.length === 0 &&
5259
- accumulatedMessages[accumulatedMessages.length - 1]?.role === "assistant";
5260
- if (isNoOpTurn) {
5261
- msgSub?.off();
5262
- logger.warn("chat.agent: turn added no new user message; skipping the model", {
5263
- chatId: currentWirePayload.chatId,
5264
- messageId: currentWirePayload.messageId,
5265
- });
5266
- await writeTurnCompleteChunk(currentWirePayload.chatId);
5267
- // Not a turn — don't consume an iteration.
5268
- turn--;
5269
- }
5270
4761
  // ── Action result handling ──────────────────────────────
5271
4762
  // For action turns, skip the turn machinery entirely.
5272
4763
  // If `onAction` returned a stream / string / UIMessage,
@@ -5276,34 +4767,34 @@ function chatAgent(options) {
5276
4767
  // The turn counter is decremented so the next iteration
5277
4768
  // sees the same `turn` value — actions don't count.
5278
4769
  if (isAction) {
5279
- if (isActionTurn(actionResult)) {
5280
- // Persist the edit before the turn starts, so a turn that is
5281
- // cancelled or runs out of memory continues from the edited
5282
- // history rather than from the snapshot the edit replaced.
5283
- // The turn then does its own hooks, completion and snapshot.
5284
- if (actionChangedHistory) {
5285
- await writeSnapshotOutsideTurn("action");
4770
+ msgSub?.off();
4771
+ if ((locals.get(chatPipeCountKey) ?? 0) === 0 &&
4772
+ isUIMessageStreamable(actionStreamResult)) {
4773
+ try {
4774
+ const resolvedOptions = resolveUIMessageStreamOptions();
4775
+ const uiStream = actionStreamResult.toUIMessageStream({
4776
+ ...resolvedOptions,
4777
+ generateMessageId: resolvedOptions.generateMessageId ?? generateMessageId,
4778
+ });
4779
+ await pipeChat(uiStream, {
4780
+ signal: combinedSignal,
4781
+ spanName: "stream response",
4782
+ });
5286
4783
  }
5287
- actionTurn = true;
5288
- }
5289
- else if (actionResult !== undefined) {
5290
- throw new Error("chat.agent: onAction returned a value. An action is a state edit; to answer " +
5291
- "after the edit, return chat.turn() and a turn runs on the edited history. " +
5292
- "Returning a StreamTextResult, string or UIMessage is no longer supported.");
5293
- }
5294
- else {
5295
- msgSub?.off();
5296
- if (actionChangedHistory) {
5297
- await writeSnapshotOutsideTurn("action");
4784
+ catch (error) {
4785
+ if (error instanceof Error &&
4786
+ error.name === "AbortError" &&
4787
+ runSignal.aborted) {
4788
+ return "exit";
4789
+ }
4790
+ throw error;
5298
4791
  }
5299
- await writeTurnCompleteChunk(currentWirePayload.chatId);
5300
- // Don't consume a turn iteration — actions aren't turns.
5301
- turn--;
5302
4792
  }
4793
+ await writeTurnCompleteChunk(currentWirePayload.chatId);
4794
+ // Don't consume a turn iteration — actions aren't turns.
4795
+ turn--;
5303
4796
  }
5304
- // A no-op turn skips this block, and with it `followSessionPin`:
5305
- // there is nothing to answer, so nothing to hand over.
5306
- if ((!isAction || actionTurn) && !isNoOpTurn) {
4797
+ if (!isAction) {
5307
4798
  // Mint a scoped public access token once per turn, reused for
5308
4799
  // onChatStart, onTurnStart, onTurnComplete, and the turn-complete chunk.
5309
4800
  const currentRunId = ctx.run.id;
@@ -5408,11 +4899,9 @@ function chatAgent(options) {
5408
4899
  },
5409
4900
  });
5410
4901
  }
5411
- await followSessionPin(currentWirePayload.chatId, versionSkew);
5412
4902
  // chat.requestUpgrade() called in onTurnStart (or onValidateMessages) —
5413
- // skip run() and hand over to a fresh run on the new version. The
5414
- // successor picks the message up off session.in; the transport only
5415
- // keeps reading.
4903
+ // skip run() and signal the transport to re-trigger the same message
4904
+ // on the new version.
5416
4905
  if (locals.get(chatUpgradeRequestedKey)) {
5417
4906
  await writeUpgradeRequiredChunk();
5418
4907
  return "exit";
@@ -5471,9 +4960,6 @@ function chatAgent(options) {
5471
4960
  const preparedMessages = await applyPrepareMessages(accumulatedMessages, "run");
5472
4961
  runResult = await userRun({
5473
4962
  ...restWire,
5474
- // A turn requested by chat.turn() is not the action itself:
5475
- // a run() that short-circuits on "action" must still answer.
5476
- ...(actionTurn ? { trigger: "action-turn" } : {}),
5477
4963
  messages: preparedMessages,
5478
4964
  clientData,
5479
4965
  continuation,
@@ -5486,7 +4972,6 @@ function chatAgent(options) {
5486
4972
  signal: combinedSignal,
5487
4973
  cancelSignal,
5488
4974
  stopSignal,
5489
- streamText: createBoundStreamText(promptRegistry, agentSystem, agentCacheControl, agentSystemProviderOptions),
5490
4975
  });
5491
4976
  }
5492
4977
  // Auto-pipe if the run function returned a StreamTextResult or similar,
@@ -5600,19 +5085,7 @@ function chatAgent(options) {
5600
5085
  if (runOverride) {
5601
5086
  locals.set(chatOverrideMessagesKey, undefined);
5602
5087
  accumulatedUIMessages = [...runOverride];
5603
- /**
5604
- * Steers the drain consumed are left out of the rebuild and
5605
- * appended by the reconciliation below instead, so the lane
5606
- * gets the form the model actually received rather than a
5607
- * reconversion of the UI message, and gets it once. A steer
5608
- * the edit removed is dropped from the pending list too, so
5609
- * the edit is honoured.
5610
- */
5611
- const overrideIds = new Set(runOverride.map((m) => m.id));
5612
- const pending = (locals.get(chatPendingSteerKey) ?? []).filter((e) => overrideIds.has(e.ui.id));
5613
- locals.set(chatPendingSteerKey, pending);
5614
- const pendingIds = new Set(pending.map((e) => e.ui.id));
5615
- accumulatedMessages = await toModelMessages(runOverride.filter((m) => !pendingIds.has(m.id)));
5088
+ accumulatedMessages = await toModelMessages(runOverride);
5616
5089
  locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
5617
5090
  }
5618
5091
  // Check if compaction set a model-only override (preserves UI messages).
@@ -5642,15 +5115,6 @@ function chatAgent(options) {
5642
5115
  }
5643
5116
  // Determine if the user stopped generation this turn (not a full run cancel).
5644
5117
  const wasStopped = stopController.signal.aborted && !runSignal.aborted;
5645
- // Give the model accumulator the steering messages the drain
5646
- // consumed. Appended, never reconverted from the UI lane, so a
5647
- // model-only compaction summary set just above survives; and done
5648
- // before the response is appended so the order stays
5649
- // steer-then-answer. Outside the `capturedResponseMessage`
5650
- // branches below, so a turn that captured no response is covered.
5651
- const steerTailThisTurn = reconcilePendingSteer({
5652
- turnNew: turnNewModelMessages,
5653
- }).reduce((n, e) => n + e.model.length, 0);
5654
5118
  // Append the assistant's response (partial or complete) to the accumulator.
5655
5119
  // The onFinish callback fires even on abort/stop, so partial responses
5656
5120
  // from stopped generation are captured correctly.
@@ -5688,7 +5152,6 @@ function chatAgent(options) {
5688
5152
  const existingIdx = capturedResponseMessage.id
5689
5153
  ? accumulatedUIMessages.findIndex((m) => m.id === capturedResponseMessage.id)
5690
5154
  : -1;
5691
- const previousAtIdx = existingIdx !== -1 ? accumulatedUIMessages[existingIdx] : undefined;
5692
5155
  if (existingIdx !== -1) {
5693
5156
  accumulatedUIMessages[existingIdx] = capturedResponseMessage;
5694
5157
  }
@@ -5708,12 +5171,8 @@ function chatAgent(options) {
5708
5171
  stripProviderMetadata(capturedResponseMessage),
5709
5172
  ]);
5710
5173
  if (existingIdx !== -1) {
5711
- const ok = previousAtIdx !== undefined &&
5712
- (await replaceModelRun(accumulatedMessages, previousAtIdx, capturedResponseMessage, steerTailThisTurn));
5713
- if (!ok) {
5714
- logger.warn("chat.agent: replaced response not found at the model lane tail; reconverting the lane");
5715
- accumulatedMessages = await toModelMessages(accumulatedUIMessages);
5716
- }
5174
+ // Reconvert all model messages since we replaced rather than appended
5175
+ accumulatedMessages = await toModelMessages(accumulatedUIMessages);
5717
5176
  }
5718
5177
  else {
5719
5178
  accumulatedMessages.push(...responseModelMessages);
@@ -6010,13 +5469,11 @@ function chatAgent(options) {
6010
5469
  try {
6011
5470
  await tracer.startActiveSpan("snapshot.write", async () => {
6012
5471
  const snapshotInCursor = chatInputRouter().resumeFloor();
6013
- lastSnapshotOutEventId =
6014
- turnCompleteResult?.lastEventId ?? lastSnapshotOutEventId;
6015
5472
  await writeChatSnapshot(sessionIdForSnapshot, {
6016
5473
  version: 1,
6017
5474
  savedAt: Date.now(),
6018
5475
  messages: accumulatedUIMessages,
6019
- lastOutEventId: lastSnapshotOutEventId,
5476
+ lastOutEventId: turnCompleteResult?.lastEventId,
6020
5477
  lastInEventId: snapshotInCursor !== undefined ? String(snapshotInCursor) : undefined,
6021
5478
  });
6022
5479
  }, {
@@ -6050,17 +5507,10 @@ function chatAgent(options) {
6050
5507
  currentWirePayload = bootInjectedQueue.shift();
6051
5508
  return "continue";
6052
5509
  }
6053
- // chat.requestUpgrade() was called — exit the loop; the handover
6054
- // has already triggered a new run on the latest version.
5510
+ // chat.requestUpgrade() was called — exit the loop so the
5511
+ // transport triggers a new run on the latest version.
6055
5512
  // chat.endRun() — same exit, no upgrade semantics.
6056
- if (locals.get(chatCloseRequestedKey)) {
6057
- await performChatClose();
6058
- return "exit";
6059
- }
6060
5513
  if (locals.get(chatUpgradeRequestedKey) || locals.get(chatEndRunRequestedKey)) {
6061
- if (locals.get(chatUpgradeRequestedKey)) {
6062
- await persistUpgradeHandoff();
6063
- }
6064
5514
  return "exit";
6065
5515
  }
6066
5516
  // Wait for the next message — stay idle briefly, then suspend
@@ -6167,10 +5617,6 @@ function chatAgent(options) {
6167
5617
  });
6168
5618
  // Signal turn complete so the client knows this turn is done
6169
5619
  errorTurnCompleteResult = await writeTurnCompleteChunk(currentWirePayload.chatId);
6170
- // A later action's snapshot reuses this cursor, so it has to move
6171
- // here too or that snapshot resumes from before the failed turn.
6172
- lastSnapshotOutEventId =
6173
- errorTurnCompleteResult?.lastEventId ?? lastSnapshotOutEventId;
6174
5620
  }
6175
5621
  catch {
6176
5622
  // Best-effort — if stream write fails, let the run continue anyway
@@ -6215,46 +5661,15 @@ function chatAgent(options) {
6215
5661
  : partialIdx === -1
6216
5662
  ? [...erroredUIMessages, partialResponse]
6217
5663
  : erroredUIMessages.map((m, i) => i === partialIdx ? partialResponse : m);
6218
- /**
6219
- * Seeded from the per-turn list, not just the wire message and the
6220
- * partial, so a steering message the drain consumed is reported too.
6221
- * An app persisting from `newUIMessages` would otherwise lose the
6222
- * instruction whenever the turn it steered went on to fail.
6223
- */
6224
- const buildErroredNew = () => {
6225
- const out = [];
6226
- const addUnique = (m) => {
6227
- if (m && !out.some((existing) => existing.id === m.id))
6228
- out.push(m);
6229
- };
6230
- addUnique(erroredWireMessage);
6231
- for (const m of (locals.get(chatTurnNewUIMessagesKey) ?? [])) {
6232
- addUnique(m);
6233
- }
6234
- if (includePartial)
6235
- addUnique(partialResponse);
6236
- return out;
6237
- };
6238
- let erroredNewUIMessages = buildErroredNew();
5664
+ let erroredNewUIMessages = erroredWireMessage ? [erroredWireMessage] : [];
5665
+ if (includePartial) {
5666
+ erroredNewUIMessages.push(partialResponse);
5667
+ }
6239
5668
  let erroredNewModelMessages = [];
6240
- const reconciledSteer = reconcilePendingSteer();
6241
5669
  if (!responseCommitted) {
6242
5670
  try {
6243
5671
  if (erroredNewUIMessages.length > 0) {
6244
- /**
6245
- * Built in order from the recorded forms rather than by
6246
- * converting the UI list, so a steer appears in the delta as
6247
- * the model received it (what `prepare` produced), matching the
6248
- * lane. The wire message and partial are converted as before.
6249
- */
6250
- const steerModelById = new Map(reconciledSteer.map((e) => [e.ui.id, e.model]));
6251
- for (const m of erroredNewUIMessages) {
6252
- const recorded = steerModelById.get(m.id);
6253
- if (recorded)
6254
- erroredNewModelMessages.push(...recorded);
6255
- else
6256
- erroredNewModelMessages.push(...(await toModelMessages([stripProviderMetadata(m)])));
6257
- }
5672
+ erroredNewModelMessages = await toModelMessages(erroredNewUIMessages.map((m) => stripProviderMetadata(m)));
6258
5673
  }
6259
5674
  if (erroredUIMessagesWithPartial !== accumulatedUIMessages) {
6260
5675
  if (partialIdx === -1) {
@@ -6262,11 +5677,7 @@ function chatAgent(options) {
6262
5677
  accumulatedMessages.push(...(await toModelMessages(appended.map((m) => stripProviderMetadata(m)))));
6263
5678
  }
6264
5679
  else {
6265
- const ok = await replaceModelRun(accumulatedMessages, erroredUIMessages[partialIdx], partialResponse, reconciledSteer.reduce((n, e) => n + e.model.length, 0));
6266
- if (!ok) {
6267
- logger.warn("chat.agent: replaced partial not found at the model lane tail; reconverting the lane");
6268
- accumulatedMessages = await toModelMessages(erroredUIMessagesWithPartial);
6269
- }
5680
+ accumulatedMessages = await toModelMessages(erroredUIMessagesWithPartial);
6270
5681
  }
6271
5682
  accumulatedUIMessages = erroredUIMessagesWithPartial;
6272
5683
  locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
@@ -6275,7 +5686,7 @@ function chatAgent(options) {
6275
5686
  catch {
6276
5687
  erroredNewModelMessages = [];
6277
5688
  erroredUIMessagesWithPartial = erroredUIMessages;
6278
- erroredNewUIMessages = buildErroredNew().filter((m) => m !== partialResponse);
5689
+ erroredNewUIMessages = erroredWireMessage ? [erroredWireMessage] : [];
6279
5690
  }
6280
5691
  }
6281
5692
  if (onTurnComplete) {
@@ -6345,15 +5756,8 @@ function chatAgent(options) {
6345
5756
  });
6346
5757
  }
6347
5758
  }
6348
- if (locals.get(chatCloseRequestedKey)) {
6349
- await performChatClose();
6350
- return;
6351
- }
6352
5759
  // chat.requestUpgrade() / chat.endRun() — exit after error turn too
6353
5760
  if (locals.get(chatUpgradeRequestedKey) || locals.get(chatEndRunRequestedKey)) {
6354
- if (locals.get(chatUpgradeRequestedKey)) {
6355
- await persistUpgradeHandoff();
6356
- }
6357
5761
  return;
6358
5762
  }
6359
5763
  // Drain remaining recovered turns before idling — a thrown
@@ -6376,12 +5780,6 @@ function chatAgent(options) {
6376
5780
  return; // Timed out — end run gracefully
6377
5781
  }
6378
5782
  currentWirePayload = next.output;
6379
- // Same close check the success path makes. Without it a close
6380
- // record that lands after a failed turn is consumed as if it were
6381
- // a turn payload, and the loop runs on against a closed session.
6382
- if (currentWirePayload.trigger === "close") {
6383
- return;
6384
- }
6385
5783
  // Continue to next iteration of the for loop
6386
5784
  }
6387
5785
  finally {
@@ -6390,11 +5788,6 @@ function chatAgent(options) {
6390
5788
  }
6391
5789
  }
6392
5790
  finally {
6393
- // Safety net for a close requested on a path that exits without
6394
- // reaching one of the loop's close checks (a turn timeout, an OOM
6395
- // re-throw). `performChatClose` is idempotent, so the ordinary path
6396
- // having already run it costs nothing here.
6397
- await performChatClose();
6398
5791
  // `stopSub` is registered post-preload so the close-during-preload
6399
5792
  // early-return path may exit before it ever attached. Guard the
6400
5793
  // cleanup so a missing subscription doesn't throw.
@@ -6732,22 +6125,15 @@ function isStopped() {
6732
6125
  // Version upgrade
6733
6126
  // ---------------------------------------------------------------------------
6734
6127
  /**
6735
- * Hand the conversation over to another deployment.
6736
- *
6737
- * The handover happens immediately and server-side: a successor run is created
6738
- * and picks the conversation up from `session.in`. The transport keeps reading
6739
- * the same session output, so no client action is needed and nothing waits for
6740
- * the next message.
6741
- *
6742
- * Without a target the session's pin is cleared, so the successor lands on the
6743
- * latest deployed version; with `externalDeploymentId` the session is re-pinned
6744
- * to that deployment.
6128
+ * Request that the current run exits so the next message starts on the latest
6129
+ * deployed version (via the standard continuation mechanism).
6745
6130
  *
6746
6131
  * When called from `onTurnStart` or `onValidateMessages`, `run()` is skipped
6747
- * entirely and the successor answers the message that opened the turn.
6132
+ * entirely — the run exits immediately and the transport re-triggers the
6133
+ * same message on the new version.
6748
6134
  *
6749
6135
  * When called from `run()` or `chat.defer()`, the current turn completes
6750
- * normally and the handover happens afterward.
6136
+ * normally and the run exits afterward instead of waiting for the next message.
6751
6137
  *
6752
6138
  * Call from `onTurnStart`, `onValidateMessages`, `onChatResume`, `run()`,
6753
6139
  * or inside `chat.defer()`.
@@ -6767,37 +6153,8 @@ function isStopped() {
6767
6153
  * });
6768
6154
  * ```
6769
6155
  */
6770
- function requestUpgrade(options) {
6156
+ function requestUpgrade() {
6771
6157
  locals.set(chatUpgradeRequestedKey, true);
6772
- // Without a target the handoff clears the session's pin; with one it re-pins to that deployment.
6773
- const target = options?.externalDeploymentId?.trim();
6774
- if (target)
6775
- locals.set(chatUpgradeExternalDeploymentIdKey, target);
6776
- }
6777
- /** @internal Requests a handoff when the session's pin no longer names this deployment. */
6778
- async function followSessionPin(chatId, policy) {
6779
- if (!chatId) {
6780
- return;
6781
- }
6782
- const deployedExternalId = locals.get(chatAgentRunContextKey)?.deployment?.externalId;
6783
- if (policy !== "hold" && !deployedExternalId) {
6784
- logger.debug("chat.versionSkew: cannot follow the session pin", {
6785
- chatId,
6786
- reason: "the run context carries no deployment.externalId",
6787
- });
6788
- }
6789
- const target = await resolvePinToFollow({
6790
- policy,
6791
- deployedExternalId,
6792
- upgradeAlreadyRequested: locals.get(chatUpgradeRequestedKey) === true,
6793
- readPin: async () => (await sessions.retrieve(chatId, { retry: { maxAttempts: 2, randomize: true } }))
6794
- .triggerConfig,
6795
- });
6796
- if (!target) {
6797
- return;
6798
- }
6799
- logger.info("chat.versionSkew: following the session pin", { chatId, target });
6800
- requestUpgrade({ externalDeploymentId: target });
6801
6158
  }
6802
6159
  /**
6803
6160
  * Hand off the current custom agent Session to a fresh run.
@@ -6836,31 +6193,20 @@ async function endAndContinue() {
6836
6193
  if ((locals.get(chatActiveSessionIteratorsKey) ?? 0) > 0) {
6837
6194
  throw new Error("chat.endAndContinue() cannot be called while a chat.createSession() iterator is active. Close the iterator, then call chat.endAndContinue().");
6838
6195
  }
6839
- await performEndAndContinue({ reason: "continuation" });
6196
+ await performEndAndContinue();
6840
6197
  }
6841
6198
  /** @internal Shared server handoff used by managed and custom agent loops. */
6842
- async function performEndAndContinue(options) {
6199
+ async function performEndAndContinue() {
6843
6200
  const chatId = locals.get(chatExternalIdKey);
6844
6201
  const callingRunId = locals.get(chatAgentRunContextKey)?.run.id;
6845
6202
  if (!chatId || !callingRunId) {
6846
6203
  throw new Error("Cannot end and continue without an active chat agent run");
6847
6204
  }
6848
- const externalDeploymentId = options.externalDeploymentId;
6849
6205
  const apiClient = apiClientManager.clientOrThrow();
6850
- const result = await apiClient.endAndContinueSession(chatId, {
6206
+ await apiClient.endAndContinueSession(chatId, {
6851
6207
  callingRunId,
6852
- reason: options.reason,
6853
- ...(externalDeploymentId ? { externalDeploymentId } : {}),
6208
+ reason: "upgrade",
6854
6209
  });
6855
- if (result?.pendingVersion !== true) {
6856
- return;
6857
- }
6858
- // The successor parked. Say so on `.out` while this run still can — the transport's
6859
- // subscription survives the swap, so the client learns without waiting for its next send.
6860
- const [error] = await tryCatch(getChatSession().out.writeControl(TRIGGER_CONTROL_SUBTYPE.PENDING_VERSION));
6861
- if (error) {
6862
- logger.warn("could not signal a parked handoff", { chatId, error });
6863
- }
6864
6210
  }
6865
6211
  /**
6866
6212
  * Exit the run after the current turn completes, without waiting for the
@@ -6891,124 +6237,6 @@ async function performEndAndContinue(options) {
6891
6237
  function endRun() {
6892
6238
  locals.set(chatEndRunRequestedKey, true);
6893
6239
  }
6894
- /**
6895
- * End the whole conversation, permanently. The session row is closed, further
6896
- * appends are refused, and the run exits without scheduling a continuation.
6897
- *
6898
- * This is the session-level stop. {@link endRun} ends the current run and lets
6899
- * the next message start a fresh one; `chat.close()` ends the session itself,
6900
- * so there is no next message. Use it for a budget cap, a completed goal,
6901
- * abuse detection, or a user signing out.
6902
- *
6903
- * In a `chat.agent`, call it from `run()`, `prepareStep`, or
6904
- * `onBeforeTurnComplete`. Called mid-step, it aborts the in-flight
6905
- * `streamText` the same way the stop signal does, so the partial response is
6906
- * still captured and streamed. The turn then completes normally, a terminal
6907
- * `session-closed` record carrying `reason` is written to the response stream,
6908
- * and the loop exits.
6909
- *
6910
- * Prefer `onBeforeTurnComplete` over `onTurnComplete`. It carries the same
6911
- * fields but still runs while the stream is open, so the closed state rides
6912
- * out on the turn's final record. `onTurnComplete` runs after that record, so
6913
- * a close decided there does not reach a reader that has already finished the
6914
- * turn, and the user only finds out when their next message is refused.
6915
- *
6916
- * In a `chat.customAgent`, call it anywhere in your own loop. The close is
6917
- * performed when `run()` returns, so it lands whether you break out of a
6918
- * `chat.createSession` loop, return early, or hand-roll the loop entirely.
6919
- *
6920
- * Closing is one-way: a closed session cannot be reopened. Its transcript
6921
- * stays readable.
6922
- *
6923
- * @example
6924
- * ```ts
6925
- * chat.agent({
6926
- * id: "budgeted-agent",
6927
- * onBeforeTurnComplete: async ({ usage }) => {
6928
- * if (await overBudget(usage)) {
6929
- * chat.close({ reason: "Monthly budget reached" });
6930
- * }
6931
- * },
6932
- * });
6933
- * ```
6934
- */
6935
- function close(options) {
6936
- if (!locals.get(chatExternalIdKey)) {
6937
- throw new Error("chat.close() can only be called from inside a chat.agent() or chat.customAgent() run");
6938
- }
6939
- // Bound the reason once, here. It goes out on S2 record headers as well as
6940
- // the close API, and an oversized value would fail the turn-complete write
6941
- // that carries the turn boundary, costing the client far more than the
6942
- // reason text.
6943
- // Trailing high surrogate: the cut landed between the two halves of an
6944
- // astral character, and encoding the orphan to UTF-8 for a record header
6945
- // yields a replacement character. Drop it rather than ship mojibake.
6946
- const reason = options?.reason
6947
- ?.slice(0, CHAT_CLOSE_REASON_MAX_LENGTH)
6948
- .replace(/[\uD800-\uDBFF]$/, "");
6949
- locals.set(chatCloseRequestedKey, reason ? { reason } : {});
6950
- // Mid-step call: unblock the in-flight streamText exactly like the stop
6951
- // signal, so the turn can reach its turn boundary instead of running the
6952
- // model out to completion after the decision to close has been made.
6953
- locals.get(chatStopControllerKey)?.abort(reason ?? "closed");
6954
- }
6955
- /**
6956
- * @internal Terminal close sequence, run once at whichever exit site observes
6957
- * the close request. Writes the standalone `session-closed` record, then closes
6958
- * the session row.
6959
- *
6960
- * The record lands after the turn's `turn-complete`, so a client reading that
6961
- * turn's stream has already terminated on it and will not see this one. It is
6962
- * there for a reconnect and for replay. What a live client reads is the
6963
- * `session-closed` header stamped onto `turn-complete` itself by
6964
- * `writeTurnCompleteChunk`, which fires whenever the close was decided before
6965
- * the turn ended. A close decided from `onTurnComplete` is past that point, so
6966
- * the client learns from the 409 on its next send.
6967
- */
6968
- async function performChatClose() {
6969
- const request = locals.get(chatCloseRequestedKey);
6970
- if (!request || locals.get(chatClosePerformedKey))
6971
- return;
6972
- const reason = request.reason;
6973
- // Two flags, not one. The record is a client-visible event and must not be
6974
- // written twice, but the row close is the part that actually ends the
6975
- // conversation: flagging it as done before it succeeds would let a transient
6976
- // failure leave the session open with no later call willing to retry.
6977
- if (!locals.get(chatCloseRecordWrittenKey)) {
6978
- locals.set(chatCloseRecordWrittenKey, true);
6979
- try {
6980
- const session = getChatSession();
6981
- await session.out.writeControl(TRIGGER_CONTROL_SUBTYPE.SESSION_CLOSED, reason ? [[SESSION_CLOSED_REASON_HEADER, reason]] : undefined);
6982
- }
6983
- catch (error) {
6984
- logger.warn("chat.close: failed to write the session-closed record", {
6985
- error: error instanceof Error ? error.message : String(error),
6986
- });
6987
- }
6988
- }
6989
- const chatId = locals.get(chatExternalIdKey);
6990
- if (!chatId)
6991
- return;
6992
- try {
6993
- await sessions.close(chatId, {
6994
- ...(reason ? { reason } : {}),
6995
- ...(locals.get(chatAgentRunContextKey)?.run.id
6996
- ? { callingRunId: locals.get(chatAgentRunContextKey).run.id }
6997
- : {}),
6998
- });
6999
- locals.set(chatClosePerformedKey, true);
7000
- }
7001
- catch (error) {
7002
- // Deliberately NOT flagged as performed: the close API is idempotent, so a
7003
- // later exit site on this run gets to retry it. Losing every retry to a
7004
- // transient failure would leave the row open and the conversation alive.
7005
- // Non-fatal either way — the run still exits.
7006
- logger.error("chat.close: failed to close the session", {
7007
- chatId,
7008
- error: error instanceof Error ? error.message : String(error),
7009
- });
7010
- }
7011
- }
7012
6240
  // ---------------------------------------------------------------------------
7013
6241
  // Per-turn deferred work
7014
6242
  // ---------------------------------------------------------------------------
@@ -7070,18 +6298,9 @@ function chatDefer(promiseOrFn) {
7070
6298
  * ```
7071
6299
  */
7072
6300
  function injectBackgroundContext(messages) {
7073
- const systemBlocks = messages.filter((message) => message.role === "system");
7074
- const conversational = messages.filter((message) => message.role !== "system");
7075
- if (systemBlocks.length > 0) {
7076
- const instructions = locals.get(chatInjectedInstructionsKey) ?? [];
7077
- instructions.push(...systemBlocks);
7078
- locals.set(chatInjectedInstructionsKey, instructions);
7079
- }
7080
- if (conversational.length > 0) {
7081
- const queue = locals.get(chatBackgroundQueueKey) ?? [];
7082
- queue.push(...conversational);
7083
- locals.set(chatBackgroundQueueKey, queue);
7084
- }
6301
+ const queue = locals.get(chatBackgroundQueueKey) ?? [];
6302
+ queue.push(...messages);
6303
+ locals.set(chatBackgroundQueueKey, queue);
7085
6304
  }
7086
6305
  // ---------------------------------------------------------------------------
7087
6306
  // Aborted message cleanup
@@ -7494,12 +6713,10 @@ class ChatMessageAccumulator {
7494
6713
  // a duplicate, mirroring the chat.agent accumulator.
7495
6714
  const existingIdx = this.uiMessages.findIndex((m) => m.id === response.id);
7496
6715
  if (existingIdx !== -1) {
7497
- const previous = this.uiMessages[existingIdx];
7498
6716
  this.uiMessages[existingIdx] = response;
7499
6717
  try {
7500
- if (!(await replaceModelRun(this.modelMessages, previous, response, 0))) {
7501
- this.modelMessages = await toModelMessages(this.uiMessages.map((m) => stripProviderMetadata(m)));
7502
- }
6718
+ // Reconvert all model messages since we replaced rather than appended.
6719
+ this.modelMessages = await toModelMessages(this.uiMessages.map((m) => stripProviderMetadata(m)));
7503
6720
  }
7504
6721
  catch {
7505
6722
  // Conversion failed — leave the existing model messages in place
@@ -7535,28 +6752,6 @@ class ChatMessageAccumulator {
7535
6752
  const modelMsgs = await toModelMessages([message]);
7536
6753
  this._steeringQueue.push({ uiMessage: message, modelMessages: modelMsgs });
7537
6754
  }
7538
- /**
7539
- * Record the messages a steering drain consumed.
7540
- *
7541
- * The drain only puts them in this step's prompt, so without this they
7542
- * shape one answer and then exist in neither lane: not in `uiMessages`,
7543
- * which is what an app persists from, and not in `modelMessages`, which is
7544
- * what every later turn sends.
7545
- *
7546
- * Both lanes are appended to. The model lane is never reconverted from the
7547
- * UI lane, because `compactIfNeeded` replaces it with a summary and leaves
7548
- * the UI lane whole: a reconversion would restore everything the summary
7549
- * replaced.
7550
- */
7551
- async absorbSteering(claimed, injected) {
7552
- const fresh = claimed.filter((m) => !this.uiMessages.some((e) => e.id === m.id));
7553
- if (fresh.length === 0)
7554
- return;
7555
- this.uiMessages.push(...fresh);
7556
- // Record what the model received. Only when the whole batch is new is
7557
- // `injected` known to describe exactly these messages.
7558
- this.modelMessages.push(...(injected && fresh.length === claimed.length ? injected : await toModelMessages(fresh)));
7559
- }
7560
6755
  /**
7561
6756
  * Get and clear unconsumed steering messages.
7562
6757
  */
@@ -7589,8 +6784,7 @@ class ChatMessageAccumulator {
7589
6784
  }
7590
6785
  // 2. Pending message injection
7591
6786
  if (pm && queue.length > 0) {
7592
- const { injected, claimed } = await drainSteeringQueue(pm, resultMessages ?? messages, steps, queue);
7593
- await this.absorbSteering(claimed, injected);
6787
+ const injected = await drainSteeringQueue(pm, resultMessages ?? messages, steps, queue);
7594
6788
  if (injected.length > 0) {
7595
6789
  resultMessages = [...(resultMessages ?? messages), ...injected];
7596
6790
  }
@@ -7779,7 +6973,7 @@ function trackActiveChatSessionIterator(iterator) {
7779
6973
  * ```
7780
6974
  */
7781
6975
  function createChatSession(payload, options) {
7782
- const { signal: runSignal, idleTimeoutInSeconds: sessionIdleTimeoutOpt, timeout = "1h", maxTurns = 100, compaction: sessionCompaction, pendingMessages: sessionPendingMessages, versionSkew: sessionVersionSkew, } = options;
6976
+ const { signal: runSignal, idleTimeoutInSeconds: sessionIdleTimeoutOpt, timeout = "1h", maxTurns = 100, compaction: sessionCompaction, pendingMessages: sessionPendingMessages, } = options;
7783
6977
  const idleTimeoutInSeconds = sessionIdleTimeoutOpt ?? 30;
7784
6978
  return {
7785
6979
  [Symbol.asyncIterator]() {
@@ -7868,16 +7062,8 @@ function createChatSession(payload, options) {
7868
7062
  * without suspending.
7869
7063
  */
7870
7064
  if (turn > 0) {
7871
- if (locals.get(chatCloseRequestedKey)) {
7872
- await performChatClose();
7873
- stop.cleanup();
7874
- return { done: true, value: undefined };
7875
- }
7876
7065
  // chat.requestUpgrade() / chat.endRun() — exit before waiting
7877
7066
  if (locals.get(chatUpgradeRequestedKey) || locals.get(chatEndRunRequestedKey)) {
7878
- if (locals.get(chatUpgradeRequestedKey)) {
7879
- await persistUpgradeHandoff();
7880
- }
7881
7067
  stop.cleanup();
7882
7068
  return { done: true, value: undefined };
7883
7069
  }
@@ -7980,7 +7166,6 @@ function createChatSession(payload, options) {
7980
7166
  }
7981
7167
  accumulator.applyHandover(pendingHandoverSignal);
7982
7168
  }
7983
- await followSessionPin(currentPayload.chatId, sessionVersionSkew);
7984
7169
  // chat.requestUpgrade() called before this turn — signal transport and exit
7985
7170
  if (locals.get(chatUpgradeRequestedKey)) {
7986
7171
  await writeUpgradeRequiredChunk();
@@ -8197,8 +7382,7 @@ function createChatSession(payload, options) {
8197
7382
  }
8198
7383
  }
8199
7384
  if (sessionPendingMessages) {
8200
- const { injected, claimed } = await drainSteeringQueue(sessionPendingMessages, resultMessages ?? stepMsgs, steps, turnSteeringQueue);
8201
- await accumulator.absorbSteering(claimed, injected);
7385
+ const injected = await drainSteeringQueue(sessionPendingMessages, resultMessages ?? stepMsgs, steps, turnSteeringQueue);
8202
7386
  if (injected.length > 0) {
8203
7387
  resultMessages = [...(resultMessages ?? stepMsgs), ...injected];
8204
7388
  }
@@ -8212,11 +7396,6 @@ function createChatSession(payload, options) {
8212
7396
  async return() {
8213
7397
  activeMsgSub?.off();
8214
7398
  activeMsgSub = undefined;
8215
- // Reached when the consumer leaves the `for await` early (`break`,
8216
- // `return`, a throw). A `chat.close()` from the loop body would
8217
- // otherwise be dropped: the exit that performs it lives in `next()`,
8218
- // and `next()` is never called again.
8219
- await performChatClose();
8220
7399
  // `stop` only exists once next() has booted the iterator.
8221
7400
  stop?.cleanup();
8222
7401
  return { done: true, value: undefined };
@@ -8473,11 +7652,6 @@ function createChatStartSessionAction(taskId, options) {
8473
7652
  const maxAttempts = params.triggerConfig?.maxAttempts ?? options?.triggerConfig?.maxAttempts;
8474
7653
  const maxDuration = params.triggerConfig?.maxDuration ?? options?.triggerConfig?.maxDuration;
8475
7654
  const idleTimeoutInSeconds = params.triggerConfig?.idleTimeoutInSeconds ?? options?.triggerConfig?.idleTimeoutInSeconds;
8476
- // Only `undefined` means "not supplied": a per-call `null` (opt out) has to beat a pinning
8477
- // action default, which neither truthiness nor `??` would allow.
8478
- const externalDeploymentId = params.triggerConfig?.externalDeploymentId !== undefined
8479
- ? params.triggerConfig.externalDeploymentId
8480
- : options?.triggerConfig?.externalDeploymentId;
8481
7655
  const triggerConfig = {
8482
7656
  basePayload: {
8483
7657
  messages: [],
@@ -8504,10 +7678,6 @@ function createChatStartSessionAction(taskId, options) {
8504
7678
  lockToVersion: params.triggerConfig?.lockToVersion ?? options?.triggerConfig?.lockToVersion,
8505
7679
  }
8506
7680
  : {}),
8507
- ...(params.triggerConfig?.ttl !== undefined || options?.triggerConfig?.ttl !== undefined
8508
- ? { ttl: params.triggerConfig?.ttl ?? options?.triggerConfig?.ttl }
8509
- : {}),
8510
- ...(externalDeploymentId !== undefined ? { externalDeploymentId } : {}),
8511
7681
  ...(idleTimeoutInSeconds !== undefined ? { idleTimeoutInSeconds } : {}),
8512
7682
  };
8513
7683
  const startBody = {
@@ -8552,7 +7722,6 @@ function createChatStartSessionAction(taskId, options) {
8552
7722
  publicAccessToken,
8553
7723
  runId: created.runId,
8554
7724
  sessionId: created.id,
8555
- ...(created.pendingVersion ? { pendingVersion: true } : {}),
8556
7725
  };
8557
7726
  };
8558
7727
  }
@@ -8586,8 +7755,7 @@ async function callSessionsCreateWithOverride(args) {
8586
7755
  const init = {
8587
7756
  method: "POST",
8588
7757
  headers: overrideRequestHeaders(accessToken),
8589
- // This path bypasses `sessions.start`, so it resolves the pin itself.
8590
- body: JSON.stringify(withResolvedExternalDeploymentId(args.body)),
7758
+ body: JSON.stringify(args.body),
8591
7759
  };
8592
7760
  const response = args.fetchOverride
8593
7761
  ? await args.fetchOverride(url, init, ctx)
@@ -8665,8 +7833,6 @@ export const chat = {
8665
7833
  createStartSessionAction: createChatStartSessionAction,
8666
7834
  /** Pipe a stream to the chat transport. See {@link pipeChat}. */
8667
7835
  pipe: pipeChat,
8668
- /** Return from `onAction` to run a turn on the edited history. See {@link chatTurn}. */
8669
- turn: chatTurn,
8670
7836
  /** Create a per-run typed local. See {@link chatLocal}. */
8671
7837
  local: chatLocal,
8672
7838
  /** Create a public access token for a chat task. See {@link createChatAccessToken}. */
@@ -8687,8 +7853,6 @@ export const chat = {
8687
7853
  endAndContinue,
8688
7854
  /** Exit the run after the current turn completes, without any upgrade signal. See {@link endRun}. */
8689
7855
  endRun,
8690
- /** End the conversation permanently: close the session and exit the run. See {@link close}. */
8691
- close,
8692
7856
  /** Clean up aborted parts from a UIMessage. See {@link cleanupAbortedParts}. */
8693
7857
  cleanupAbortedParts,
8694
7858
  /** Register background work that runs in parallel with streaming. See {@link chatDefer}. */
@@ -8836,16 +8000,6 @@ async function writeTurnCompleteChunk(_chatId, publicAccessToken) {
8836
8000
  if (consumedCursor !== undefined) {
8837
8001
  extraHeaders.push([SESSION_IN_CONSUMED_ID_HEADER, String(consumedCursor)]);
8838
8002
  }
8839
- // A close decided before this turn ended rides out on turn-complete. Readers
8840
- // terminate their stream on turn-complete, so a standalone record written
8841
- // after it only reaches a reconnect — this header is what a live client sees.
8842
- const pendingClose = locals.get(chatCloseRequestedKey);
8843
- if (pendingClose) {
8844
- extraHeaders.push([SESSION_CLOSED_HEADER, "true"]);
8845
- if (pendingClose.reason) {
8846
- extraHeaders.push([SESSION_CLOSED_REASON_HEADER, pendingClose.reason]);
8847
- }
8848
- }
8849
8003
  const result = await session.out.writeControl(TRIGGER_CONTROL_SUBTYPE.TURN_COMPLETE, extraHeaders);
8850
8004
  const T_N = result.lastEventId ? Number.parseInt(result.lastEventId, 10) : undefined;
8851
8005
  // 2. Trim back to the previous turn-complete, if we have one. Skipping on
@@ -8903,47 +8057,12 @@ async function writeTurnCompleteChunk(_chatId, publicAccessToken) {
8903
8057
  *
8904
8058
  * @internal
8905
8059
  */
8906
- /**
8907
- * Persists an upgrade requested after the turn has already run.
8908
- *
8909
- * The pre-turn sites reach {@link performEndAndContinue} through
8910
- * {@link writeUpgradeRequiredChunk}, which is what clears (or re-points) the
8911
- * session's stored `externalDeploymentId`. The post-turn exits had no such path,
8912
- * so a `chat.requestUpgrade()` from `run()` or `chat.defer()` left the pin intact
8913
- * and every continuation re-pinned to the deployment the agent asked to leave.
8914
- *
8915
- * No `upgrade-required` chunk is written here: the turn already produced its
8916
- * answer, so there is nothing for a client to be told about.
8917
- */
8918
- async function persistUpgradeHandoff() {
8919
- const chatId = locals.get(chatExternalIdKey);
8920
- const callingRunId = locals.get(chatAgentRunContextKey)?.run.id;
8921
- if (!chatId || !callingRunId) {
8922
- return;
8923
- }
8924
- try {
8925
- await performEndAndContinue({
8926
- reason: "upgrade",
8927
- externalDeploymentId: locals.get(chatUpgradeExternalDeploymentIdKey),
8928
- });
8929
- }
8930
- catch (error) {
8931
- logger.warn("upgrade handoff failed; session keeps its current version pin", {
8932
- chatId,
8933
- callingRunId,
8934
- error,
8935
- });
8936
- }
8937
- }
8938
8060
  async function writeUpgradeRequiredChunk() {
8939
8061
  const chatId = locals.get(chatExternalIdKey);
8940
8062
  const callingRunId = locals.get(chatAgentRunContextKey)?.run.id;
8941
8063
  if (chatId && callingRunId) {
8942
8064
  try {
8943
- await performEndAndContinue({
8944
- reason: "upgrade",
8945
- externalDeploymentId: locals.get(chatUpgradeExternalDeploymentIdKey),
8946
- });
8065
+ await performEndAndContinue();
8947
8066
  }
8948
8067
  catch (error) {
8949
8068
  // Non-fatal: the next `.in/append` re-triggers via the probe.