@trigger.dev/sdk 4.5.0-rc.5 → 4.5.0-rc.6
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.
- package/dist/commonjs/v3/ai.d.ts +7 -0
- package/dist/commonjs/v3/ai.js +303 -106
- package/dist/commonjs/v3/ai.js.map +1 -1
- package/dist/commonjs/v3/chat-client.js +3 -0
- package/dist/commonjs/v3/chat-client.js.map +1 -1
- package/dist/commonjs/v3/chat-react.js +10 -7
- package/dist/commonjs/v3/chat-react.js.map +1 -1
- package/dist/commonjs/v3/chat.js +34 -6
- package/dist/commonjs/v3/chat.js.map +1 -1
- package/dist/commonjs/v3/chat.test.js +53 -0
- package/dist/commonjs/v3/chat.test.js.map +1 -1
- package/dist/commonjs/v3/sessions.d.ts +8 -4
- package/dist/commonjs/v3/sessions.js +7 -3
- package/dist/commonjs/v3/sessions.js.map +1 -1
- package/dist/commonjs/v3/test/mock-chat-agent.d.ts +6 -0
- package/dist/commonjs/v3/test/mock-chat-agent.js +1 -0
- package/dist/commonjs/v3/test/mock-chat-agent.js.map +1 -1
- package/dist/commonjs/version.js +1 -1
- package/dist/esm/v3/ai.d.ts +7 -0
- package/dist/esm/v3/ai.js +303 -107
- package/dist/esm/v3/ai.js.map +1 -1
- package/dist/esm/v3/chat-client.js +3 -0
- package/dist/esm/v3/chat-client.js.map +1 -1
- package/dist/esm/v3/chat-react.js +10 -7
- package/dist/esm/v3/chat-react.js.map +1 -1
- package/dist/esm/v3/chat.js +34 -6
- package/dist/esm/v3/chat.js.map +1 -1
- package/dist/esm/v3/chat.test.js +53 -0
- package/dist/esm/v3/chat.test.js.map +1 -1
- package/dist/esm/v3/sessions.d.ts +8 -4
- package/dist/esm/v3/sessions.js +7 -3
- package/dist/esm/v3/sessions.js.map +1 -1
- package/dist/esm/v3/test/mock-chat-agent.d.ts +6 -0
- package/dist/esm/v3/test/mock-chat-agent.js +1 -0
- package/dist/esm/v3/test/mock-chat-agent.js.map +1 -1
- package/dist/esm/version.js +1 -1
- package/package.json +4 -3
package/dist/esm/v3/ai.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { accessoryAttributes, apiClientManager, getSchemaParseFn, headerValue, InputStreamOncePromise, isSchemaZodEsque, logger, ManualWaitpointPromise, OutOfMemoryError, sessionStreams, SemanticInternalAttributes, taskContext, SESSION_IN_EVENT_ID_HEADER, TRIGGER_CONTROL_SUBTYPE, generateJWT, } from "@trigger.dev/core/v3";
|
|
1
|
+
import { accessoryAttributes, apiClientManager, controlSubtype, getSchemaParseFn, headerValue, InputStreamOncePromise, isSchemaZodEsque, logger, ManualWaitpointPromise, OutOfMemoryError, sessionStreams, SemanticInternalAttributes, taskContext, SESSION_IN_EVENT_ID_HEADER, TRIGGER_CONTROL_SUBTYPE, generateJWT, } 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 { convertToModelMessages, dynamicTool, generateId as generateMessageId, getToolName, isToolUIPart, jsonSchema, readUIMessageStream, tool as aiTool, zodSchema, } from "../imports/ai-runtime.js";
|
|
@@ -68,51 +68,41 @@ const lastTurnCompleteSeqNumKey = locals.create("chat.lastTurnCompleteSeqNum");
|
|
|
68
68
|
* the `.in` subscription so already-processed user messages don't get
|
|
69
69
|
* replayed from S2.
|
|
70
70
|
*
|
|
71
|
-
* Implementation
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
71
|
+
* Implementation is a non-blocking records read (`wait=0`) — the
|
|
72
|
+
* endpoint returns everything currently stored (including pre-trim
|
|
73
|
+
* records, since S2 trims are eventually consistent) in one shot, and
|
|
74
|
+
* we keep the LAST matching header. The previous SSE-based scan had to
|
|
75
|
+
* idle-wait a full 5s window to know it reached the tail, which put a
|
|
76
|
+
* constant ~6s tax on every continuation boot.
|
|
77
77
|
*
|
|
78
78
|
* Returns `undefined` if no `turn-complete` carrying the header has been
|
|
79
79
|
* written yet — first-turn-ever, first turn post-OOM-with-no-prior-runs,
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
* messages.
|
|
80
|
+
* a `turn-complete` written before this header existed, or a server old
|
|
81
|
+
* enough that the records endpoint doesn't serialize headers. Callers
|
|
82
|
+
* fall back to subscribing `.in` from seq 0 in that case; the slim-wire
|
|
83
|
+
* merge handles any dedup against snapshot-restored messages.
|
|
84
84
|
* @internal
|
|
85
85
|
*/
|
|
86
86
|
async function findLatestSessionInCursor(chatId) {
|
|
87
87
|
const apiClient = apiClientManager.clientOrThrow();
|
|
88
|
+
const response = await apiClient.readSessionStreamRecords(chatId, "out");
|
|
88
89
|
let latestCursor;
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
if (event.subtype !== TRIGGER_CONTROL_SUBTYPE.TURN_COMPLETE)
|
|
99
|
-
return;
|
|
100
|
-
const raw = headerValue(event.headers, SESSION_IN_EVENT_ID_HEADER);
|
|
101
|
-
if (!raw)
|
|
102
|
-
return;
|
|
103
|
-
const parsed = Number.parseInt(raw, 10);
|
|
104
|
-
if (Number.isFinite(parsed))
|
|
105
|
-
latestCursor = parsed;
|
|
106
|
-
},
|
|
107
|
-
});
|
|
108
|
-
// Drain the stream so the underlying SSE reader runs to completion. We
|
|
109
|
-
// don't accumulate chunks; `onControl` fires inline as turn-complete
|
|
110
|
-
// records arrive.
|
|
111
|
-
for await (const _ of stream) {
|
|
112
|
-
// intentionally empty
|
|
90
|
+
for (const record of response.records) {
|
|
91
|
+
if (controlSubtype(record.headers) !== TRIGGER_CONTROL_SUBTYPE.TURN_COMPLETE)
|
|
92
|
+
continue;
|
|
93
|
+
const raw = headerValue(record.headers, SESSION_IN_EVENT_ID_HEADER);
|
|
94
|
+
if (!raw)
|
|
95
|
+
continue;
|
|
96
|
+
const parsed = Number.parseInt(raw, 10);
|
|
97
|
+
if (Number.isFinite(parsed))
|
|
98
|
+
latestCursor = parsed;
|
|
113
99
|
}
|
|
114
100
|
return latestCursor;
|
|
115
101
|
}
|
|
102
|
+
/** Test-only entry point for the records-based cursor scan. @internal */
|
|
103
|
+
export async function __findLatestSessionInCursorForTests(chatId) {
|
|
104
|
+
return findLatestSessionInCursor(chatId);
|
|
105
|
+
}
|
|
116
106
|
let readChatSnapshotImpl;
|
|
117
107
|
export function __setReadChatSnapshotImplForTests(impl) {
|
|
118
108
|
readChatSnapshotImpl = impl;
|
|
@@ -980,8 +970,15 @@ const messagesInput = {
|
|
|
980
970
|
on(handler) {
|
|
981
971
|
return getChatSession().in.on((chunk) => {
|
|
982
972
|
if (chunk.kind === "message") {
|
|
983
|
-
|
|
973
|
+
// Returning `true` marks the record CONSUMED at the manager level:
|
|
974
|
+
// it is neither buffered for a later `once()` nor re-delivered by
|
|
975
|
+
// the buffer drain when the next turn re-attaches its handler.
|
|
976
|
+
// Without this, a message arriving mid-stream was delivered twice
|
|
977
|
+
// and ran a duplicate turn.
|
|
978
|
+
void Promise.resolve(handler(chunk.payload)).catch(() => { });
|
|
979
|
+
return true;
|
|
984
980
|
}
|
|
981
|
+
return undefined;
|
|
985
982
|
});
|
|
986
983
|
},
|
|
987
984
|
once(options) {
|
|
@@ -1075,8 +1072,13 @@ const stopInput = {
|
|
|
1075
1072
|
on(handler) {
|
|
1076
1073
|
return getChatSession().in.on((chunk) => {
|
|
1077
1074
|
if (chunk.kind === "stop") {
|
|
1078
|
-
|
|
1075
|
+
// Consume stop records (see the messages facade above). A stop is
|
|
1076
|
+
// only meaningful to the turn it interrupts — buffering it would
|
|
1077
|
+
// let a stale stop abort a future turn.
|
|
1078
|
+
void Promise.resolve(handler({ stop: true, message: chunk.message })).catch(() => { });
|
|
1079
|
+
return true;
|
|
1079
1080
|
}
|
|
1081
|
+
return undefined;
|
|
1080
1082
|
});
|
|
1081
1083
|
},
|
|
1082
1084
|
once(options) {
|
|
@@ -1230,6 +1232,9 @@ const chatHandoverIsFinalKey = locals.create("chat.handoverIsFinal");
|
|
|
1230
1232
|
* `tool-approval-response` rows are AI-SDK-internal and don't need a
|
|
1231
1233
|
* UIMessage representation. We map:
|
|
1232
1234
|
* - `text` parts → `{ type: "text", text }`
|
|
1235
|
+
* - `reasoning` parts → `{ type: "reasoning", text, state: "done" }`
|
|
1236
|
+
* (provider metadata carried so an Anthropic thinking signature
|
|
1237
|
+
* survives a UIMessage → ModelMessage round trip)
|
|
1233
1238
|
* - `tool-call` parts → `{ type: "tool-${name}", toolCallId,
|
|
1234
1239
|
* state: "input-available", input }`
|
|
1235
1240
|
* - `tool-approval-request` parts → skipped (AI SDK derives the
|
|
@@ -1246,6 +1251,14 @@ function synthesizeHandoverUIMessage(partial, messageId) {
|
|
|
1246
1251
|
if (part.type === "text" && typeof part.text === "string") {
|
|
1247
1252
|
parts.push({ type: "text", text: part.text });
|
|
1248
1253
|
}
|
|
1254
|
+
else if (part.type === "reasoning" && typeof part.text === "string") {
|
|
1255
|
+
parts.push({
|
|
1256
|
+
type: "reasoning",
|
|
1257
|
+
text: part.text,
|
|
1258
|
+
state: "done",
|
|
1259
|
+
...(part.providerOptions ? { providerMetadata: part.providerOptions } : {}),
|
|
1260
|
+
});
|
|
1261
|
+
}
|
|
1249
1262
|
else if (part.type === "tool-call" && part.toolCallId && part.toolName) {
|
|
1250
1263
|
parts.push({
|
|
1251
1264
|
type: `tool-${part.toolName}`,
|
|
@@ -2590,6 +2603,11 @@ function chatCustomAgent(options) {
|
|
|
2590
2603
|
// No client-side upsert needed.
|
|
2591
2604
|
locals.set(chatSessionHandleKey, sessions.open(payload.chatId));
|
|
2592
2605
|
locals.set(chatAgentRunContextKey, runOptions.ctx);
|
|
2606
|
+
// Initialize the turn-complete trim slot so `chat.writeTurnComplete`
|
|
2607
|
+
// trims `session.out` back to the previous turn boundary. Without
|
|
2608
|
+
// this the slot is undefined and the trim never runs, so `.out`
|
|
2609
|
+
// grows without bound for the whole custom-agent surface.
|
|
2610
|
+
locals.set(lastTurnCompleteSeqNumKey, { value: undefined });
|
|
2593
2611
|
markChatAgentRunForStreamsWarning();
|
|
2594
2612
|
taskContext.setConversationId(payload.chatId);
|
|
2595
2613
|
stampConversationIdOnActiveSpan(payload.chatId);
|
|
@@ -2715,6 +2733,11 @@ function chatAgent(options) {
|
|
|
2715
2733
|
// `messagesInput.waitWithIdleTimeout` so recovered turns fire first.
|
|
2716
2734
|
const bootInjectedQueue = [];
|
|
2717
2735
|
const couldHavePriorState = payload.continuation === true || ctx.attempt.number > 1;
|
|
2736
|
+
// `.in` resume cursor, computed at most once per boot. The boot
|
|
2737
|
+
// block below resolves it (snapshot field or records scan) and the
|
|
2738
|
+
// resume-cursor block reuses it instead of re-scanning.
|
|
2739
|
+
let bootInCursor;
|
|
2740
|
+
let bootInCursorResolved = false;
|
|
2718
2741
|
if (!hydrateMessages && couldHavePriorState) {
|
|
2719
2742
|
// Single parent span for the whole boot read phase — snapshot
|
|
2720
2743
|
// read, session.out replay, session.in replay. Per-phase timing
|
|
@@ -2750,23 +2773,28 @@ function chatAgent(options) {
|
|
|
2750
2773
|
slot.value = seeded;
|
|
2751
2774
|
}
|
|
2752
2775
|
}
|
|
2753
|
-
//
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
2776
|
+
// The `.out` replay and the `.in` cursor + tail read are
|
|
2777
|
+
// independent (both depend only on the snapshot) — run them
|
|
2778
|
+
// concurrently. Each phase keeps its own catch + duration
|
|
2779
|
+
// attribute.
|
|
2780
|
+
const replayOutPhase = async () => {
|
|
2781
|
+
const replayOutStart = Date.now();
|
|
2782
|
+
try {
|
|
2783
|
+
const replayResult = await replaySessionOutTail(sessionIdForSnapshot, { lastEventId: bootSnapshot?.lastOutEventId });
|
|
2784
|
+
replayedSettled = replayResult.settled;
|
|
2785
|
+
replayedPartial = replayResult.partial;
|
|
2786
|
+
replayedPartialRaw = replayResult.partialRaw;
|
|
2787
|
+
}
|
|
2788
|
+
catch (error) {
|
|
2789
|
+
logger.warn("chat.agent: session.out replay failed; using snapshot only", {
|
|
2790
|
+
error: error instanceof Error ? error.message : String(error),
|
|
2791
|
+
sessionId: sessionIdForSnapshot,
|
|
2792
|
+
});
|
|
2793
|
+
}
|
|
2794
|
+
bootSpan.setAttribute("chat.boot.replay.out.durationMs", Date.now() - replayOutStart);
|
|
2795
|
+
bootSpan.setAttribute("chat.boot.replay.out.settledCount", replayedSettled.length);
|
|
2796
|
+
bootSpan.setAttribute("chat.boot.replay.out.partialPresent", replayedPartial !== undefined);
|
|
2797
|
+
};
|
|
2770
2798
|
// session.in tail read
|
|
2771
2799
|
//
|
|
2772
2800
|
// session.in carries the user-side of the conversation
|
|
@@ -2777,20 +2805,43 @@ function chatAgent(options) {
|
|
|
2777
2805
|
// visible via the live SSE subscription — by which point they
|
|
2778
2806
|
// would arrive AFTER the partial-assistant orphan and look like
|
|
2779
2807
|
// brand-new turns to the model, producing inverted chains.
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
2808
|
+
//
|
|
2809
|
+
// The cursor comes from the snapshot when present (written
|
|
2810
|
+
// there since `lastInEventId` was added) — otherwise from a
|
|
2811
|
+
// records scan of `.out`'s latest turn-complete header.
|
|
2812
|
+
const replayInPhase = async () => {
|
|
2813
|
+
const replayInStart = Date.now();
|
|
2814
|
+
const snapshotInCursor = bootSnapshot?.lastInEventId !== undefined
|
|
2815
|
+
? Number.parseInt(bootSnapshot.lastInEventId, 10)
|
|
2816
|
+
: undefined;
|
|
2817
|
+
if (snapshotInCursor !== undefined && Number.isFinite(snapshotInCursor)) {
|
|
2818
|
+
bootInCursor = snapshotInCursor;
|
|
2819
|
+
bootInCursorResolved = true;
|
|
2820
|
+
}
|
|
2821
|
+
else {
|
|
2822
|
+
try {
|
|
2823
|
+
bootInCursor = await findLatestSessionInCursor(payload.chatId);
|
|
2824
|
+
bootInCursorResolved = true;
|
|
2825
|
+
}
|
|
2826
|
+
catch {
|
|
2827
|
+
// Transient scan failure: leave unresolved so the
|
|
2828
|
+
// resume-cursor block below retries the lookup.
|
|
2829
|
+
bootInCursor = undefined;
|
|
2830
|
+
}
|
|
2831
|
+
}
|
|
2832
|
+
bootSpan.setAttribute("chat.boot.replay.in.cursorFromSnapshot", snapshotInCursor !== undefined);
|
|
2833
|
+
try {
|
|
2834
|
+
replayedInTail = await replaySessionInTail(payload.chatId, {
|
|
2835
|
+
lastEventId: bootInCursor !== undefined ? String(bootInCursor) : undefined,
|
|
2836
|
+
});
|
|
2837
|
+
}
|
|
2838
|
+
catch (error) {
|
|
2839
|
+
logger.warn("chat.agent: session.in replay failed; in-flight users may not be recovered", { error: error instanceof Error ? error.message : String(error) });
|
|
2840
|
+
}
|
|
2841
|
+
bootSpan.setAttribute("chat.boot.replay.in.durationMs", Date.now() - replayInStart);
|
|
2842
|
+
bootSpan.setAttribute("chat.boot.replay.in.userCount", replayedInTail.length);
|
|
2843
|
+
};
|
|
2844
|
+
await Promise.all([replayOutPhase(), replayInPhase()]);
|
|
2794
2845
|
}, {
|
|
2795
2846
|
attributes: {
|
|
2796
2847
|
[SemanticInternalAttributes.STYLE_ICON]: "tabler-rotate-clockwise",
|
|
@@ -2831,7 +2882,12 @@ function chatAgent(options) {
|
|
|
2831
2882
|
bootSnapshot !== undefined;
|
|
2832
2883
|
if (needsResumeCursor) {
|
|
2833
2884
|
try {
|
|
2834
|
-
|
|
2885
|
+
// Reuse the cursor the boot block already resolved (snapshot
|
|
2886
|
+
// field or records scan) — only scan here when the boot block
|
|
2887
|
+
// was skipped (hydrateMessages, or snapshot-only signals).
|
|
2888
|
+
const cursor = bootInCursorResolved
|
|
2889
|
+
? bootInCursor
|
|
2890
|
+
: await findLatestSessionInCursor(payload.chatId);
|
|
2835
2891
|
if (cursor !== undefined) {
|
|
2836
2892
|
sessionStreams.setLastSeqNum(payload.chatId, "in", cursor);
|
|
2837
2893
|
sessionStreams.setLastDispatchedSeqNum(payload.chatId, "in", cursor);
|
|
@@ -3595,6 +3651,18 @@ function chatAgent(options) {
|
|
|
3595
3651
|
// therefore a delta merge, not a full-history reset.
|
|
3596
3652
|
if (currentWirePayload.trigger !== "action") {
|
|
3597
3653
|
let cleanedUIMessages = cleanedIncomingMessages;
|
|
3654
|
+
// Turn-0 head-start with hydrateMessages: the boot seeding from
|
|
3655
|
+
// `payload.headStartMessages` is non-hydrate-only, so ship the
|
|
3656
|
+
// route handler's first-turn history to the hydrate hook as
|
|
3657
|
+
// incoming messages instead (gated on the pending handover).
|
|
3658
|
+
if (turn === 0 &&
|
|
3659
|
+
hydrateMessages &&
|
|
3660
|
+
cleanedUIMessages.length === 0 &&
|
|
3661
|
+
(locals.get(chatHandoverPartialKey)?.length ?? 0) > 0 &&
|
|
3662
|
+
Array.isArray(payload.headStartMessages) &&
|
|
3663
|
+
payload.headStartMessages.length > 0) {
|
|
3664
|
+
cleanedUIMessages = payload.headStartMessages;
|
|
3665
|
+
}
|
|
3598
3666
|
// Validate/transform UIMessages before conversion — catches malformed
|
|
3599
3667
|
// messages from storage or untrusted input before they reach the model.
|
|
3600
3668
|
// Slim wire: triggers like `regenerate-message` carry no incoming
|
|
@@ -3773,40 +3841,47 @@ function chatAgent(options) {
|
|
|
3773
3841
|
// `preload` / `close` / `handover-prepare` and submits
|
|
3774
3842
|
// with no incoming message fall through with the boot-
|
|
3775
3843
|
// seeded accumulator unchanged.
|
|
3776
|
-
|
|
3777
|
-
|
|
3778
|
-
|
|
3779
|
-
|
|
3780
|
-
|
|
3781
|
-
|
|
3782
|
-
|
|
3783
|
-
|
|
3784
|
-
|
|
3785
|
-
|
|
3786
|
-
|
|
3787
|
-
|
|
3788
|
-
|
|
3789
|
-
|
|
3790
|
-
|
|
3791
|
-
|
|
3792
|
-
|
|
3793
|
-
|
|
3794
|
-
|
|
3795
|
-
|
|
3796
|
-
|
|
3797
|
-
|
|
3798
|
-
|
|
3844
|
+
}
|
|
3845
|
+
if (turn === 0) {
|
|
3846
|
+
// Head-start handover splice (turn 0 only, BOTH
|
|
3847
|
+
// accumulation branches — hydrate and default): the
|
|
3848
|
+
// `chat.handover` route handler signalled a mid-turn
|
|
3849
|
+
// handover, so splice its partial assistant response
|
|
3850
|
+
// (text + pending tool-calls + the synthesized
|
|
3851
|
+
// tool-approval round) onto the accumulator.
|
|
3852
|
+
// `streamText` then hits AI SDK's initial-tool-
|
|
3853
|
+
// execution branch, runs the agent-side tool executes,
|
|
3854
|
+
// and resumes from step 2 — skipping the first model
|
|
3855
|
+
// call (already done by the handler).
|
|
3856
|
+
//
|
|
3857
|
+
// We also synthesize a UIMessage form of the partial
|
|
3858
|
+
// assistant and push it to `accumulatedUIMessages` so
|
|
3859
|
+
// AI SDK's `processUIMessageStream` (invoked when the
|
|
3860
|
+
// run loop calls `runResult.toUIMessageStream({
|
|
3861
|
+
// onFinish })`) can initialize `state.message` from
|
|
3862
|
+
// the trailing assistant in `originalMessages`. Without
|
|
3863
|
+
// that, the `tool-output-available` chunks emitted by
|
|
3864
|
+
// the initial-tool-execution branch can't find their
|
|
3865
|
+
// matching tool-call in state and AI SDK throws
|
|
3866
|
+
// `UIMessageStreamError: No tool invocation found`.
|
|
3867
|
+
const pendingHandoverPartial = locals.get(chatHandoverPartialKey);
|
|
3868
|
+
if (pendingHandoverPartial && pendingHandoverPartial.length > 0) {
|
|
3869
|
+
const handoverMessageId = locals.get(chatHandoverMessageIdKey);
|
|
3870
|
+
// Skip if the hydrated chain already persisted the
|
|
3871
|
+
// partial under the handover messageId.
|
|
3872
|
+
const alreadyInChain = handoverMessageId !== undefined &&
|
|
3873
|
+
accumulatedUIMessages.some((m) => m.id === handoverMessageId);
|
|
3874
|
+
if (!alreadyInChain) {
|
|
3799
3875
|
accumulatedMessages.push(...pendingHandoverPartial);
|
|
3800
|
-
const handoverMessageId = locals.get(chatHandoverMessageIdKey);
|
|
3801
3876
|
const partialUI = synthesizeHandoverUIMessage(pendingHandoverPartial, handoverMessageId);
|
|
3802
3877
|
if (partialUI) {
|
|
3803
3878
|
accumulatedUIMessages.push(partialUI);
|
|
3804
3879
|
}
|
|
3805
|
-
locals.set(chatHandoverPartialKey, []); // consume once
|
|
3806
3880
|
}
|
|
3881
|
+
locals.set(chatHandoverPartialKey, []); // consume once
|
|
3807
3882
|
}
|
|
3808
|
-
locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
|
|
3809
3883
|
}
|
|
3884
|
+
locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
|
|
3810
3885
|
} // end if (trigger !== "action")
|
|
3811
3886
|
// ── Action result handling ──────────────────────────────
|
|
3812
3887
|
// For action turns, skip the turn machinery entirely.
|
|
@@ -4503,11 +4578,15 @@ function chatAgent(options) {
|
|
|
4503
4578
|
if (!hydrateMessages) {
|
|
4504
4579
|
try {
|
|
4505
4580
|
await tracer.startActiveSpan("snapshot.write", async () => {
|
|
4581
|
+
const snapshotInCursor = getChatSession().in.lastDispatchedSeqNum();
|
|
4506
4582
|
await writeChatSnapshot(sessionIdForSnapshot, {
|
|
4507
4583
|
version: 1,
|
|
4508
4584
|
savedAt: Date.now(),
|
|
4509
4585
|
messages: accumulatedUIMessages,
|
|
4510
4586
|
lastOutEventId: turnCompleteResult?.lastEventId,
|
|
4587
|
+
lastInEventId: snapshotInCursor !== undefined
|
|
4588
|
+
? String(snapshotInCursor)
|
|
4589
|
+
: undefined,
|
|
4511
4590
|
});
|
|
4512
4591
|
}, {
|
|
4513
4592
|
attributes: {
|
|
@@ -4644,17 +4723,100 @@ function chatAgent(options) {
|
|
|
4644
4723
|
if (turnError instanceof OutOfMemoryError) {
|
|
4645
4724
|
throw turnError;
|
|
4646
4725
|
}
|
|
4726
|
+
let errorTurnCompleteResult;
|
|
4647
4727
|
try {
|
|
4648
4728
|
await withChatWriter(async (writer) => {
|
|
4649
4729
|
const errorText = turnError instanceof Error ? turnError.message : "An unexpected error occurred";
|
|
4650
4730
|
writer.write({ type: "error", errorText });
|
|
4651
4731
|
});
|
|
4652
4732
|
// Signal turn complete so the client knows this turn is done
|
|
4653
|
-
await writeTurnCompleteChunk(currentWirePayload.chatId);
|
|
4733
|
+
errorTurnCompleteResult = await writeTurnCompleteChunk(currentWirePayload.chatId);
|
|
4654
4734
|
}
|
|
4655
4735
|
catch {
|
|
4656
4736
|
// Best-effort — if stream write fails, let the run continue anyway
|
|
4657
4737
|
}
|
|
4738
|
+
// The submit-message merge into the accumulator may not have run
|
|
4739
|
+
// yet (a pre-run hook threw), so fold the wire message in for the
|
|
4740
|
+
// error event + snapshot — the cursor has already advanced past it,
|
|
4741
|
+
// so otherwise it survives in neither the snapshot nor the `.in` tail.
|
|
4742
|
+
const erroredWireMessage = currentWirePayload.message;
|
|
4743
|
+
const erroredUIMessages = erroredWireMessage &&
|
|
4744
|
+
!accumulatedUIMessages.some((m) => m.id === erroredWireMessage.id)
|
|
4745
|
+
? [...accumulatedUIMessages, erroredWireMessage]
|
|
4746
|
+
: accumulatedUIMessages;
|
|
4747
|
+
// Fire onTurnComplete on the error path too — the docs promise it
|
|
4748
|
+
// runs "after every turn, successful or errored" so customers can
|
|
4749
|
+
// mark the turn failed. `responseMessage` is undefined/partial and
|
|
4750
|
+
// `error` carries the thrown value.
|
|
4751
|
+
if (onTurnComplete) {
|
|
4752
|
+
try {
|
|
4753
|
+
await tracer.startActiveSpan("onTurnComplete()", async () => {
|
|
4754
|
+
await onTurnComplete({
|
|
4755
|
+
ctx,
|
|
4756
|
+
chatId: currentWirePayload.chatId,
|
|
4757
|
+
messages: accumulatedMessages,
|
|
4758
|
+
uiMessages: erroredUIMessages,
|
|
4759
|
+
newMessages: [],
|
|
4760
|
+
newUIMessages: erroredWireMessage ? [erroredWireMessage] : [],
|
|
4761
|
+
responseMessage: undefined,
|
|
4762
|
+
rawResponseMessage: undefined,
|
|
4763
|
+
turn,
|
|
4764
|
+
runId: ctx.run.id,
|
|
4765
|
+
chatAccessToken: "",
|
|
4766
|
+
// Parsed `clientData` isn't reliably in scope here (parsing
|
|
4767
|
+
// may itself be the failure), and the raw metadata is the
|
|
4768
|
+
// wrong shape — leave it undefined on the error path.
|
|
4769
|
+
clientData: undefined,
|
|
4770
|
+
stopped: false,
|
|
4771
|
+
continuation,
|
|
4772
|
+
previousRunId,
|
|
4773
|
+
preloaded,
|
|
4774
|
+
totalUsage: cumulativeUsage,
|
|
4775
|
+
finishReason: "error",
|
|
4776
|
+
error: turnError,
|
|
4777
|
+
lastEventId: errorTurnCompleteResult?.lastEventId,
|
|
4778
|
+
});
|
|
4779
|
+
}, {
|
|
4780
|
+
attributes: {
|
|
4781
|
+
[SemanticInternalAttributes.STYLE_ICON]: "task-hook-onComplete",
|
|
4782
|
+
[SemanticInternalAttributes.COLLAPSED]: true,
|
|
4783
|
+
"chat.id": currentWirePayload.chatId,
|
|
4784
|
+
"chat.turn": turn + 1,
|
|
4785
|
+
"chat.errored": true,
|
|
4786
|
+
},
|
|
4787
|
+
});
|
|
4788
|
+
}
|
|
4789
|
+
catch {
|
|
4790
|
+
// A throwing onTurnComplete on the error path must not crash
|
|
4791
|
+
// the run — keep the conversation alive for the next message.
|
|
4792
|
+
}
|
|
4793
|
+
}
|
|
4794
|
+
// Persist a snapshot so the failed turn's user message isn't
|
|
4795
|
+
// stranded. `writeTurnCompleteChunk` already advanced the `.in`
|
|
4796
|
+
// cursor past it (via the session-in-event-id header), and the
|
|
4797
|
+
// success-path snapshot write is skipped on error — without this
|
|
4798
|
+
// the next boot would resume past a message that exists in
|
|
4799
|
+
// neither the snapshot nor the replayable `.in` tail.
|
|
4800
|
+
if (!hydrateMessages) {
|
|
4801
|
+
try {
|
|
4802
|
+
const errorSnapshotInCursor = getChatSession().in.lastDispatchedSeqNum();
|
|
4803
|
+
await writeChatSnapshot(sessionIdForSnapshot, {
|
|
4804
|
+
version: 1,
|
|
4805
|
+
savedAt: Date.now(),
|
|
4806
|
+
messages: erroredUIMessages,
|
|
4807
|
+
lastOutEventId: errorTurnCompleteResult?.lastEventId,
|
|
4808
|
+
lastInEventId: errorSnapshotInCursor !== undefined
|
|
4809
|
+
? String(errorSnapshotInCursor)
|
|
4810
|
+
: undefined,
|
|
4811
|
+
});
|
|
4812
|
+
}
|
|
4813
|
+
catch (error) {
|
|
4814
|
+
logger.warn("chat.agent: error-path snapshot write failed", {
|
|
4815
|
+
error: error instanceof Error ? error.message : String(error),
|
|
4816
|
+
sessionId: sessionIdForSnapshot,
|
|
4817
|
+
});
|
|
4818
|
+
}
|
|
4819
|
+
}
|
|
4658
4820
|
// chat.requestUpgrade() / chat.endRun() — exit after error turn too
|
|
4659
4821
|
if (locals.get(chatUpgradeRequestedKey) ||
|
|
4660
4822
|
locals.get(chatEndRunRequestedKey)) {
|
|
@@ -5520,18 +5682,32 @@ function createChatSession(payload, options) {
|
|
|
5520
5682
|
return {
|
|
5521
5683
|
async next() {
|
|
5522
5684
|
turn++;
|
|
5523
|
-
// First turn:
|
|
5524
|
-
|
|
5685
|
+
// First turn: wait when the boot payload carries no message.
|
|
5686
|
+
// Preload boots wait for the first real message; continuation
|
|
5687
|
+
// boots (fresh run via `ensureRunForSession` / end-and-continue)
|
|
5688
|
+
// arrive with the sticky boot-payload fields stripped, so running
|
|
5689
|
+
// a turn immediately would invoke the model with no user input.
|
|
5690
|
+
const isMessagelessContinuationBoot = currentPayload.continuation === true && !currentPayload.message;
|
|
5691
|
+
if (turn === 0 && (currentPayload.trigger === "preload" || isMessagelessContinuationBoot)) {
|
|
5525
5692
|
const result = await messagesInput.waitWithIdleTimeout({
|
|
5526
5693
|
idleTimeoutInSeconds: sessionIdleTimeoutOpt ?? currentPayload.idleTimeoutInSeconds ?? 30,
|
|
5527
5694
|
timeout,
|
|
5528
|
-
spanName:
|
|
5695
|
+
spanName: currentPayload.trigger === "preload"
|
|
5696
|
+
? "waiting for first message"
|
|
5697
|
+
: "waiting for first message (continuation)",
|
|
5529
5698
|
});
|
|
5530
5699
|
if (!result.ok || runSignal.aborted) {
|
|
5531
5700
|
stop.cleanup();
|
|
5532
5701
|
return { done: true, value: undefined };
|
|
5533
5702
|
}
|
|
5703
|
+
const continuationBoot = isMessagelessContinuationBoot;
|
|
5534
5704
|
currentPayload = result.output;
|
|
5705
|
+
// Preserve the continuation flag — the wire payload of the next
|
|
5706
|
+
// message doesn't carry it, and `turn.continuation` is how the
|
|
5707
|
+
// user knows to seed history (e.g. `turn.setMessages(stored)`).
|
|
5708
|
+
if (continuationBoot && currentPayload.continuation === undefined) {
|
|
5709
|
+
currentPayload = { ...currentPayload, continuation: true };
|
|
5710
|
+
}
|
|
5535
5711
|
}
|
|
5536
5712
|
// Subsequent turns: wait for the next message
|
|
5537
5713
|
if (turn > 0) {
|
|
@@ -5684,14 +5860,22 @@ function createChatSession(payload, options) {
|
|
|
5684
5860
|
locals.set(chatResponsePartsKey, []);
|
|
5685
5861
|
}
|
|
5686
5862
|
}
|
|
5687
|
-
// Capture token usage from the streamText result
|
|
5863
|
+
// Capture token usage from the streamText result. Race with a 2s
|
|
5864
|
+
// timeout — on stop-abort the AI SDK's totalUsage promise can hang
|
|
5865
|
+
// indefinitely, which would wedge the turn loop (same guard as
|
|
5866
|
+
// chat.agent's turn loop).
|
|
5688
5867
|
let turnUsage;
|
|
5689
5868
|
if (typeof source.totalUsage?.then === "function") {
|
|
5690
5869
|
try {
|
|
5691
|
-
const usage = await
|
|
5692
|
-
|
|
5693
|
-
|
|
5694
|
-
|
|
5870
|
+
const usage = (await Promise.race([
|
|
5871
|
+
source.totalUsage,
|
|
5872
|
+
new Promise((r) => setTimeout(() => r(undefined), 2_000)),
|
|
5873
|
+
]));
|
|
5874
|
+
if (usage) {
|
|
5875
|
+
turnUsage = usage;
|
|
5876
|
+
previousTurnUsage = usage;
|
|
5877
|
+
cumulativeUsage = addUsage(cumulativeUsage, usage);
|
|
5878
|
+
}
|
|
5695
5879
|
}
|
|
5696
5880
|
catch {
|
|
5697
5881
|
/* non-fatal */
|
|
@@ -6034,7 +6218,8 @@ function createChatStartSessionAction(taskId, options) {
|
|
|
6034
6218
|
// run-list filter by chat works without the customer having to wire it
|
|
6035
6219
|
// up. Mirrors the browser-mediated `TriggerChatTransport.doStart` path.
|
|
6036
6220
|
const userTags = params.triggerConfig?.tags ?? options?.triggerConfig?.tags ?? [];
|
|
6037
|
-
|
|
6221
|
+
// Platform cap is 10 tags per run; the auto chat tag takes one slot.
|
|
6222
|
+
const tags = [`chat:${params.chatId}`, ...userTags].slice(0, 10);
|
|
6038
6223
|
const clientDataMetadata = params.clientData !== undefined ? { metadata: params.clientData } : {};
|
|
6039
6224
|
const triggerConfig = {
|
|
6040
6225
|
basePayload: {
|
|
@@ -6332,8 +6517,19 @@ async function writeTurnCompleteChunk(_chatId, publicAccessToken) {
|
|
|
6332
6517
|
// 2. Trim back to the previous turn-complete, if we have one. Skipping on
|
|
6333
6518
|
// first-turn-ever (or first turn post-OOM without a snapshot seed) is
|
|
6334
6519
|
// fine — the chain catches up next turn.
|
|
6335
|
-
|
|
6336
|
-
|
|
6520
|
+
//
|
|
6521
|
+
// Lazily create the slot if a caller reached here without one (a plain
|
|
6522
|
+
// `task()` driving `chat.createSession` / `chat.writeTurnComplete`, vs.
|
|
6523
|
+
// chatAgent/chatCustomAgent which seed it at boot). The first call then
|
|
6524
|
+
// does no trim (nothing before it) and records its seq; later calls trim
|
|
6525
|
+
// — so `.out` is bounded for every writeTurnComplete caller, not just the
|
|
6526
|
+
// built-in agents.
|
|
6527
|
+
let slot = locals.get(lastTurnCompleteSeqNumKey);
|
|
6528
|
+
if (!slot) {
|
|
6529
|
+
slot = { value: undefined };
|
|
6530
|
+
locals.set(lastTurnCompleteSeqNumKey, slot);
|
|
6531
|
+
}
|
|
6532
|
+
const prev = slot.value;
|
|
6337
6533
|
if (slot && prev !== undefined) {
|
|
6338
6534
|
try {
|
|
6339
6535
|
await session.out.trimTo(prev);
|