@truefoundry/assistant-ui-runtime 0.1.0-rc.1
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/README.md +567 -0
- package/dist/index.d.ts +300 -0
- package/dist/index.js +4440 -0
- package/dist/index.js.map +1 -0
- package/package.json +70 -0
- package/src/agentSessionModule.d.ts +25 -0
- package/src/agentSpec.ts +44 -0
- package/src/askUserQuestion.ts +37 -0
- package/src/attachmentAdapter.test.ts +56 -0
- package/src/attachmentAdapter.ts +58 -0
- package/src/bindDraftAgentSession.test.ts +55 -0
- package/src/bindDraftAgentSession.ts +66 -0
- package/src/buildEditedUserMessageContent.test.ts +61 -0
- package/src/collectPending.test.ts +69 -0
- package/src/collectPending.ts +165 -0
- package/src/constants.ts +2 -0
- package/src/convertTurnMessages.test.ts +1991 -0
- package/src/convertTurnMessages.ts +1251 -0
- package/src/createSubAgent.ts +8 -0
- package/src/draftAgentConfig.test.ts +88 -0
- package/src/draftSessionBridge.ts +28 -0
- package/src/extractTurnUserText.ts +21 -0
- package/src/foldPeerThreads.test.ts +386 -0
- package/src/foldPeerThreads.ts +587 -0
- package/src/hooks.ts +123 -0
- package/src/index.ts +68 -0
- package/src/lastUserMessageText.ts +19 -0
- package/src/loadSessionSnapshot.test.ts +57 -0
- package/src/loadSessionSnapshot.ts +35 -0
- package/src/mcpAuth.ts +44 -0
- package/src/messageCustomMetadata.ts +37 -0
- package/src/modelMessageContent.ts +135 -0
- package/src/modelMessageImageContent.test.ts +109 -0
- package/src/modelMessageImageContent.ts +193 -0
- package/src/requiredActionInputs.test.ts +394 -0
- package/src/requiredActionInputs.ts +65 -0
- package/src/requiredActionsFromActiveUpdate.test.ts +95 -0
- package/src/sessionListStartTimestamp.ts +6 -0
- package/src/sessionSnapshot.ts +107 -0
- package/src/sessions.ts +36 -0
- package/src/streamTurn.test.ts +243 -0
- package/src/streamTurn.ts +119 -0
- package/src/toolApproval.test.ts +137 -0
- package/src/toolApproval.ts +459 -0
- package/src/toolResponse.test.ts +136 -0
- package/src/toolResponse.ts +427 -0
- package/src/truefoundryDraftThreadListAdapter.test.ts +140 -0
- package/src/truefoundryDraftThreadListAdapter.ts +63 -0
- package/src/truefoundryExtras.ts +45 -0
- package/src/truefoundryThreadListAdapter.test.ts +103 -0
- package/src/truefoundryThreadListAdapter.ts +59 -0
- package/src/turnEventHelpers.ts +98 -0
- package/src/turnStreamUpdate.ts +11 -0
- package/src/types.ts +92 -0
- package/src/useDraftAgentSpec.ts +173 -0
- package/src/useTrueFoundryAgentMessages.test.tsx +723 -0
- package/src/useTrueFoundryAgentMessages.ts +757 -0
- package/src/useTrueFoundryAgentRuntime.ts +268 -0
|
@@ -0,0 +1,1251 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AppendMessage,
|
|
3
|
+
CompleteAttachment,
|
|
4
|
+
ExportedMessageRepositoryItem,
|
|
5
|
+
MessageStatus,
|
|
6
|
+
ThreadMessage,
|
|
7
|
+
ThreadUserMessagePart,
|
|
8
|
+
} from "@assistant-ui/core";
|
|
9
|
+
import type {
|
|
10
|
+
AgentSession,
|
|
11
|
+
McpAuthRequiredEvent,
|
|
12
|
+
Turn,
|
|
13
|
+
TurnEvent,
|
|
14
|
+
TurnInputItem,
|
|
15
|
+
TurnStreamData,
|
|
16
|
+
} from "truefoundry-gateway-sdk/agents";
|
|
17
|
+
|
|
18
|
+
import { ROOT_THREAD_ID } from "./constants.js";
|
|
19
|
+
import {
|
|
20
|
+
buildRootAssistantContent,
|
|
21
|
+
buildRootAssistantContentForIds,
|
|
22
|
+
findFirstPendingApprovalThreadId,
|
|
23
|
+
findFirstPendingResponseThreadId,
|
|
24
|
+
ingestStreamEvent,
|
|
25
|
+
ingestTurnEvent,
|
|
26
|
+
PeerThreadFoldState,
|
|
27
|
+
} from "./foldPeerThreads.js";
|
|
28
|
+
import {
|
|
29
|
+
appendMcpAuthToTurnContent,
|
|
30
|
+
appendToolApprovalToTurnContent,
|
|
31
|
+
} from "./turnEventHelpers.js";
|
|
32
|
+
import type { AssistantContentPart } from "./modelMessageContent.js";
|
|
33
|
+
import {
|
|
34
|
+
extractImageUrlFromUserContentItem,
|
|
35
|
+
imageUrlToAttachment,
|
|
36
|
+
} from "./modelMessageImageContent.js";
|
|
37
|
+
import {
|
|
38
|
+
type SessionSnapshot,
|
|
39
|
+
type ProjectSessionMessagesOptions,
|
|
40
|
+
type SessionTurnRecord,
|
|
41
|
+
type RequiredActionsOverlay,
|
|
42
|
+
createEmptySessionSnapshot,
|
|
43
|
+
emptyRequiredActionsOverlay,
|
|
44
|
+
replaceSessionSnapshot,
|
|
45
|
+
turnToSessionRecord,
|
|
46
|
+
} from "./sessionSnapshot.js";
|
|
47
|
+
import {
|
|
48
|
+
applyApprovalDecisionsToContent,
|
|
49
|
+
collectApprovalDecisionsFromTurnInput,
|
|
50
|
+
collectSubsequentApprovalDecisions,
|
|
51
|
+
messageHasPendingApprovals,
|
|
52
|
+
TOOL_APPROVAL_THREAD_ID_CUSTOM_KEY,
|
|
53
|
+
toolApprovalMessageCustom,
|
|
54
|
+
toolApprovalStatus,
|
|
55
|
+
} from "./toolApproval.js";
|
|
56
|
+
import {
|
|
57
|
+
applyStagedResponsesToContent,
|
|
58
|
+
applyUserToolResponsesToFold,
|
|
59
|
+
collectSubsequentToolResponses,
|
|
60
|
+
collectToolResponsesFromTurnInput,
|
|
61
|
+
messageHasPendingResponses,
|
|
62
|
+
TOOL_RESPONSE_THREAD_ID_CUSTOM_KEY,
|
|
63
|
+
toolResponseMessageCustom,
|
|
64
|
+
toolResponseStatus,
|
|
65
|
+
} from "./toolResponse.js";
|
|
66
|
+
import type { TurnStreamUpdate } from "./turnStreamUpdate.js";
|
|
67
|
+
import {
|
|
68
|
+
buildMcpAuthTextParts,
|
|
69
|
+
mcpAuthAssistantStatus,
|
|
70
|
+
mcpAuthMessageCustom,
|
|
71
|
+
} from "./mcpAuth.js";
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Turn / event → assistant-ui normalization
|
|
75
|
+
*
|
|
76
|
+
* This module is the projection layer: it turns gateway session state into
|
|
77
|
+
* `ThreadMessage[]` for `@assistant-ui/core`. The hook in
|
|
78
|
+
* `useTrueFoundryAgentMessages.ts` owns the mutable `SessionSnapshot`; this
|
|
79
|
+
* file defines how that snapshot is built and rendered.
|
|
80
|
+
*
|
|
81
|
+
* ## Data model
|
|
82
|
+
*
|
|
83
|
+
* Gateway side:
|
|
84
|
+
* - A **session** has ordered **turns** (each with `input`, `state`, `listEvents`).
|
|
85
|
+
* - A turn's **input** is either a user message (`user.message`) or a continuation
|
|
86
|
+
* (`user.tool_response`, `user.tool_approval`, …).
|
|
87
|
+
* - **Events** (`model.message`, `tool.*`, `thread.created`, …) arrive on a
|
|
88
|
+
* **thread id**; the root conversation uses `ROOT_THREAD_ID` (`"main"`).
|
|
89
|
+
*
|
|
90
|
+
* Local side (`SessionSnapshot` in `sessionSnapshot.ts`):
|
|
91
|
+
* - `fold` — accumulated events across all threads (see `foldPeerThreads.ts`).
|
|
92
|
+
* - `turns` — committed turn records, each optionally storing
|
|
93
|
+
* `rootModelMessageIds` (root-thread `model.message` ids ingested with that turn).
|
|
94
|
+
* - `pendingUser` / `activeStream` — optimistic UI while a turn is in flight.
|
|
95
|
+
* - `groupRootBaseline` — root `model.message` ids that existed before the active
|
|
96
|
+
* turn group started; used to scope live streaming to the current group only.
|
|
97
|
+
* - `requiredActions` — locally staged approval/response decisions before resume.
|
|
98
|
+
*
|
|
99
|
+
* Output:
|
|
100
|
+
* - Alternating **user** / **assistant** `ThreadMessage` pairs.
|
|
101
|
+
* - One assistant message per **turn group** (user turn + its continuation turns).
|
|
102
|
+
*
|
|
103
|
+
* ## Turn groups
|
|
104
|
+
*
|
|
105
|
+
* A **turn group** starts at a turn whose input contains `user.message`. Later
|
|
106
|
+
* turns with only continuation inputs (tool response, tool approval, MCP resume)
|
|
107
|
+
* belong to the same group and are folded into the same assistant message.
|
|
108
|
+
*
|
|
109
|
+
* Root content for a group is the union of `rootModelMessageIds` on every turn
|
|
110
|
+
* in that group. Prior groups must not leak in: scope with
|
|
111
|
+
* `rootModelMessageIdsSinceBaseline(fold, baseline)` where
|
|
112
|
+
* `baseline = groupRootBaseline ?? computeGroupRootBaseline(turns)`.
|
|
113
|
+
*
|
|
114
|
+
* ## Pipeline
|
|
115
|
+
*
|
|
116
|
+
* 1. **Ingest** — `ingestStreamEvent` / `ingestTurnEvent` append events into
|
|
117
|
+
* per-thread buckets in `PeerThreadFoldState`. Deltas merge in place.
|
|
118
|
+
*
|
|
119
|
+
* 2. **Fold to content** — `buildRootAssistantContentForIds` walks scoped
|
|
120
|
+
* `model.message` ids and emits assistant-ui parts (reasoning, text, tool-call).
|
|
121
|
+
* Sub-agent child threads are attached under `create_sub_agent` tool-calls as
|
|
122
|
+
* nested `messages` (see `attachSubAgentMessages` in `foldPeerThreads.ts`).
|
|
123
|
+
*
|
|
124
|
+
* 3. **History projection** — `projectHistoryTurns` walks committed `turns`:
|
|
125
|
+
* - User turn → push user message; push assistant if the group has content.
|
|
126
|
+
* - Continuation turn → merge assistant content into the last assistant message
|
|
127
|
+
* in the group (same `*-assistant` id as the user turn that opened the group).
|
|
128
|
+
* - Apply answers from later continuation turns onto earlier tool-calls via
|
|
129
|
+
* `collectSubsequentApprovalDecisions` / `collectSubsequentToolResponses`.
|
|
130
|
+
* - A paused group's assistant message keeps `requires-action` until a later
|
|
131
|
+
* continuation turn resolves all its approvals/responses, then downgrades
|
|
132
|
+
* to the turn-state status (`complete`, `error`, `cancelled`).
|
|
133
|
+
*
|
|
134
|
+
* 4. **Live projection** — `projectSessionMessages` = history + `pendingUser` +
|
|
135
|
+
* `activeStream`. While streaming, `streamTurnEvents` yields content scoped to
|
|
136
|
+
* `groupRootBaseline`. When `streamComplete`, `projectActiveStreamUpdate`
|
|
137
|
+
* rebuilds from the fold (same baseline scoping as streaming).
|
|
138
|
+
*
|
|
139
|
+
* 5. **Staged overlay** — Before the SDK resume turn is sent, user decisions sit
|
|
140
|
+
* in `requiredActions` and are merged onto messages by
|
|
141
|
+
* `applyRequiredActionsOverlayToMessages` so the UI shows interrupt + result
|
|
142
|
+
* together. Resume is batched: all pending approvals and ask-user answers in
|
|
143
|
+
* a paused message must be resolved before `sendTurn({ inputs })`.
|
|
144
|
+
*
|
|
145
|
+
* ## Required actions (assistant-ui mapping)
|
|
146
|
+
*
|
|
147
|
+
* | Gateway event / input | assistant-ui representation |
|
|
148
|
+
* |-----------------------------|------------------------------------------------------|
|
|
149
|
+
* | `tool.approval_required` | `tool-call` with `approval: { id }`, status |
|
|
150
|
+
* | | `requires-action` / `tool-calls`, custom thread id |
|
|
151
|
+
* | `user.tool_approval` | closes `tool.approval_required`: sets `approval.approved` |
|
|
152
|
+
* | | (+ optional reason) on tool-call |
|
|
153
|
+
* | `tool.response_required` | `tool-call` with `interrupt: { type: "human", … }` |
|
|
154
|
+
* | (ask_user_question) | payload parsed from tool args (`askUserQuestion.ts`) |
|
|
155
|
+
* | `user.tool_response` | closes `tool.response_required`: sets `result` on |
|
|
156
|
+
* | | tool-call and clears the pending `interrupt` |
|
|
157
|
+
* | `mcp.auth_required` | Appended auth link text parts; status `interrupt` |
|
|
158
|
+
*
|
|
159
|
+
* Sub-agent threads can have their own pending approval/response; metadata
|
|
160
|
+
* `toolApprovalThreadId` / `toolResponseThreadId` on nested assistant messages
|
|
161
|
+
* scopes resume inputs to the correct thread.
|
|
162
|
+
*
|
|
163
|
+
* A sub-agent stays attached under the `create_sub_agent` tool-call that spawned
|
|
164
|
+
* it, which lives in the root `model.message` of the turn that opened the group.
|
|
165
|
+
* The child thread keeps producing events across several turns (spawn turn, then
|
|
166
|
+
* continuation turns that answer its required actions), and all of them render
|
|
167
|
+
* under that single tool-call in the group's one assistant message.
|
|
168
|
+
*
|
|
169
|
+
* INVARIANT — while a sub-agent is active the parent agent is paused awaiting a
|
|
170
|
+
* required action, so every following turn until it finishes is a continuation
|
|
171
|
+
* (`user.tool_response` / `user.tool_approval` / MCP resume), NOT a `user.message`.
|
|
172
|
+
* If a sub-agent is spawned/active in turn 1, turn 2 cannot carry `user.message`
|
|
173
|
+
* input. A `user.message` there would open a new turn group (`userText` boundary)
|
|
174
|
+
* and split the still-running sub-agent's events away from its tool-call. The
|
|
175
|
+
* gateway enforces this; the projection relies on it for correct nesting.
|
|
176
|
+
*
|
|
177
|
+
* ## Assumptions
|
|
178
|
+
*
|
|
179
|
+
* - Root-thread model messages use `threadId === ROOT_THREAD_ID`.
|
|
180
|
+
* - Turn order in `snapshot.turns` matches gateway chronological order.
|
|
181
|
+
* - `rootModelMessageIds` on committed turns is accurate (set in
|
|
182
|
+
* `commitActiveStream` when a stream completes).
|
|
183
|
+
* - Continuation turns never carry `user.message`; that boundary defines groups.
|
|
184
|
+
* In particular, while a sub-agent (or any tool) is mid-flight awaiting a
|
|
185
|
+
* required action, the next turn is always a continuation, never a new
|
|
186
|
+
* `user.message`.
|
|
187
|
+
* - Tool-call identity is stable via `toolCallId` across events, fold state, and
|
|
188
|
+
* assistant-ui parts.
|
|
189
|
+
* - Reload (`buildSnapshotFromSession`) and live paths must produce the same
|
|
190
|
+
* per-group scoping; history uses cumulative `groupRootIds` per turn index,
|
|
191
|
+
* live uses `groupRootBaseline`.
|
|
192
|
+
*/
|
|
193
|
+
|
|
194
|
+
const TURN_EVENTS_PAGE_SIZE = 25;
|
|
195
|
+
|
|
196
|
+
export type ConvertTurnsResult = {
|
|
197
|
+
messages: ThreadMessage[];
|
|
198
|
+
foldState: PeerThreadFoldState;
|
|
199
|
+
runningTurn?: Turn;
|
|
200
|
+
unstable_resume?: boolean;
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
function assistantStatusFromTurnState(state: Turn["state"]): MessageStatus {
|
|
204
|
+
switch (state.status) {
|
|
205
|
+
case "done":
|
|
206
|
+
return { type: "complete", reason: "stop" };
|
|
207
|
+
case "error":
|
|
208
|
+
return { type: "incomplete", reason: "error", error: state.message };
|
|
209
|
+
case "cancelled":
|
|
210
|
+
return { type: "incomplete", reason: "cancelled" };
|
|
211
|
+
case "running":
|
|
212
|
+
return { type: "running" };
|
|
213
|
+
default:
|
|
214
|
+
return { type: "complete", reason: "unknown" };
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function resolveCreatedAt(
|
|
219
|
+
messageId: string,
|
|
220
|
+
fallback: Date,
|
|
221
|
+
options?: ProjectSessionMessagesOptions,
|
|
222
|
+
): Date {
|
|
223
|
+
return options?.getCreatedAt?.(messageId, fallback) ?? fallback;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function parseDataUriMime(data: string): string {
|
|
227
|
+
if (!data.startsWith("data:")) {
|
|
228
|
+
return "application/octet-stream";
|
|
229
|
+
}
|
|
230
|
+
const match = /^data:([^;,]+)/.exec(data);
|
|
231
|
+
return match?.[1] ?? "application/octet-stream";
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function fileContentToAttachment(
|
|
235
|
+
file: FileContent,
|
|
236
|
+
attachmentId: string,
|
|
237
|
+
): CompleteAttachment {
|
|
238
|
+
const mimeType = parseDataUriMime(file.data);
|
|
239
|
+
if (mimeType.startsWith("image/")) {
|
|
240
|
+
return {
|
|
241
|
+
id: attachmentId,
|
|
242
|
+
type: "image",
|
|
243
|
+
name: file.name,
|
|
244
|
+
contentType: mimeType,
|
|
245
|
+
status: { type: "complete" },
|
|
246
|
+
content: [{ type: "image", image: file.data, filename: file.name }],
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
return {
|
|
250
|
+
id: attachmentId,
|
|
251
|
+
type: "file",
|
|
252
|
+
name: file.name,
|
|
253
|
+
contentType: mimeType,
|
|
254
|
+
status: { type: "complete" },
|
|
255
|
+
content: [
|
|
256
|
+
{
|
|
257
|
+
type: "file",
|
|
258
|
+
mimeType,
|
|
259
|
+
filename: file.name,
|
|
260
|
+
data: file.data,
|
|
261
|
+
},
|
|
262
|
+
],
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Projects gateway turn input onto an assistant-ui user message (text + attachments). */
|
|
267
|
+
export function buildUserMessageFromTurnInput(
|
|
268
|
+
turnId: string,
|
|
269
|
+
input: Turn["input"],
|
|
270
|
+
createdAt: string | Date,
|
|
271
|
+
options?: ProjectSessionMessagesOptions,
|
|
272
|
+
): ThreadMessage {
|
|
273
|
+
const fallback = createdAt instanceof Date ? createdAt : new Date(createdAt);
|
|
274
|
+
const id = `${turnId}-user`;
|
|
275
|
+
const content: ThreadUserMessagePart[] = [];
|
|
276
|
+
const attachments: CompleteAttachment[] = [];
|
|
277
|
+
|
|
278
|
+
for (const item of input ?? []) {
|
|
279
|
+
if (item.type !== "user.message") {
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
const messageContent = item.content;
|
|
283
|
+
if (typeof messageContent === "string") {
|
|
284
|
+
content.push({ type: "text", text: messageContent });
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
for (const part of messageContent) {
|
|
288
|
+
if (part.type === "text") {
|
|
289
|
+
content.push({ type: "text", text: part.text });
|
|
290
|
+
} else if (part.type === "file") {
|
|
291
|
+
attachments.push(
|
|
292
|
+
fileContentToAttachment(
|
|
293
|
+
part,
|
|
294
|
+
`${turnId}-file-${attachments.length}`,
|
|
295
|
+
),
|
|
296
|
+
);
|
|
297
|
+
} else {
|
|
298
|
+
const imageUrl = extractImageUrlFromUserContentItem(part);
|
|
299
|
+
if (imageUrl != null) {
|
|
300
|
+
attachments.push(
|
|
301
|
+
imageUrlToAttachment(
|
|
302
|
+
imageUrl,
|
|
303
|
+
`${turnId}-file-${attachments.length}`,
|
|
304
|
+
),
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return {
|
|
312
|
+
id,
|
|
313
|
+
role: "user",
|
|
314
|
+
content: content.length > 0 ? content : [{ type: "text", text: "" }],
|
|
315
|
+
attachments,
|
|
316
|
+
createdAt: resolveCreatedAt(id, fallback, options),
|
|
317
|
+
metadata: { custom: {} },
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function buildAssistantMessage(
|
|
322
|
+
turnId: string,
|
|
323
|
+
content: AssistantContentPart[],
|
|
324
|
+
createdAt: string | Date,
|
|
325
|
+
status: MessageStatus,
|
|
326
|
+
custom: Record<string, unknown> = {},
|
|
327
|
+
options?: ProjectSessionMessagesOptions,
|
|
328
|
+
): ThreadMessage {
|
|
329
|
+
const fallback = createdAt instanceof Date ? createdAt : new Date(createdAt);
|
|
330
|
+
const id = `${turnId}-assistant`;
|
|
331
|
+
return {
|
|
332
|
+
id,
|
|
333
|
+
role: "assistant",
|
|
334
|
+
content,
|
|
335
|
+
status,
|
|
336
|
+
createdAt: resolveCreatedAt(id, fallback, options),
|
|
337
|
+
metadata: {
|
|
338
|
+
unstable_state: null,
|
|
339
|
+
unstable_annotations: [],
|
|
340
|
+
unstable_data: [],
|
|
341
|
+
steps: [],
|
|
342
|
+
custom,
|
|
343
|
+
},
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async function ingestTurnEventsIntoFold(
|
|
348
|
+
foldState: PeerThreadFoldState,
|
|
349
|
+
turn: Pick<Turn, "listEvents">,
|
|
350
|
+
): Promise<void> {
|
|
351
|
+
for await (const event of await turn.listEvents({
|
|
352
|
+
order: "asc",
|
|
353
|
+
limit: TURN_EVENTS_PAGE_SIZE,
|
|
354
|
+
})) {
|
|
355
|
+
ingestTurnEvent(foldState, event);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async function fetchTurnEvents(
|
|
360
|
+
turn: Pick<Turn, "listEvents">,
|
|
361
|
+
): Promise<TurnEvent[]> {
|
|
362
|
+
const events: TurnEvent[] = [];
|
|
363
|
+
for await (const event of await turn.listEvents({
|
|
364
|
+
order: "asc",
|
|
365
|
+
limit: TURN_EVENTS_PAGE_SIZE,
|
|
366
|
+
})) {
|
|
367
|
+
events.push(event);
|
|
368
|
+
}
|
|
369
|
+
return events;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function ingestCollectedEventsIntoFold(
|
|
373
|
+
foldState: PeerThreadFoldState,
|
|
374
|
+
events: TurnEvent[],
|
|
375
|
+
): void {
|
|
376
|
+
for (const event of events) {
|
|
377
|
+
ingestTurnEvent(foldState, event);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async function fetchAllTurnEventsWithConcurrency(
|
|
382
|
+
turns: Turn[],
|
|
383
|
+
concurrency: number,
|
|
384
|
+
): Promise<TurnEvent[][]> {
|
|
385
|
+
const results: TurnEvent[][] = new Array(turns.length);
|
|
386
|
+
const pool = new Set<Promise<void>>();
|
|
387
|
+
for (let i = 0; i < turns.length; i++) {
|
|
388
|
+
const idx = i;
|
|
389
|
+
const turn = turns[idx]!;
|
|
390
|
+
const p: Promise<void> = fetchTurnEvents(turn).then((events) => {
|
|
391
|
+
results[idx] = events;
|
|
392
|
+
pool.delete(p);
|
|
393
|
+
});
|
|
394
|
+
pool.add(p);
|
|
395
|
+
if (pool.size >= concurrency) await Promise.race(pool);
|
|
396
|
+
}
|
|
397
|
+
await Promise.all(pool);
|
|
398
|
+
return results;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
export function rootModelMessageIdsSinceBaseline(
|
|
402
|
+
foldState: PeerThreadFoldState,
|
|
403
|
+
baseline: readonly string[],
|
|
404
|
+
): string[] {
|
|
405
|
+
const bucket = foldState.threads.get(ROOT_THREAD_ID);
|
|
406
|
+
if (bucket == null) {
|
|
407
|
+
return [];
|
|
408
|
+
}
|
|
409
|
+
if (baseline.length === 0) {
|
|
410
|
+
return [...bucket.modelMessageIds];
|
|
411
|
+
}
|
|
412
|
+
const baselineSet = new Set(baseline);
|
|
413
|
+
return bucket.modelMessageIds.filter((id) => !baselineSet.has(id));
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
export function computeGroupRootBaseline(turns: SessionTurnRecord[]): string[] {
|
|
417
|
+
let groupStartIndex = turns.length - 1;
|
|
418
|
+
while (groupStartIndex >= 0 && turns[groupStartIndex]?.userText == null) {
|
|
419
|
+
groupStartIndex--;
|
|
420
|
+
}
|
|
421
|
+
const baseline: string[] = [];
|
|
422
|
+
for (let i = 0; i < groupStartIndex; i++) {
|
|
423
|
+
baseline.push(...(turns[i]?.rootModelMessageIds ?? []));
|
|
424
|
+
}
|
|
425
|
+
return baseline;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function buildTurnUpdateFromFold(
|
|
429
|
+
foldState: PeerThreadFoldState,
|
|
430
|
+
turn: Pick<Turn, "state">,
|
|
431
|
+
rootModelMessageIds: readonly string[],
|
|
432
|
+
): TurnStreamUpdate {
|
|
433
|
+
const content = buildRootAssistantContentForIds(foldState, rootModelMessageIds);
|
|
434
|
+
let update: TurnStreamUpdate = { content };
|
|
435
|
+
update = appendMcpAuthToTurnContent(update.content, turn);
|
|
436
|
+
update = appendToolApprovalToTurnContent(update, turn);
|
|
437
|
+
return update;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function contentHasPendingRequiredActions(
|
|
441
|
+
content: readonly AssistantContentPart[],
|
|
442
|
+
): boolean {
|
|
443
|
+
const message: ThreadMessage = {
|
|
444
|
+
id: "pending-check",
|
|
445
|
+
role: "assistant",
|
|
446
|
+
content,
|
|
447
|
+
status: { type: "complete", reason: "stop" },
|
|
448
|
+
createdAt: new Date(),
|
|
449
|
+
metadata: {
|
|
450
|
+
unstable_state: null,
|
|
451
|
+
unstable_annotations: [],
|
|
452
|
+
unstable_data: [],
|
|
453
|
+
steps: [],
|
|
454
|
+
custom: {},
|
|
455
|
+
},
|
|
456
|
+
};
|
|
457
|
+
return messageHasPendingApprovals(message) || messageHasPendingResponses(message);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function resolveProjectedRequiredActionState(
|
|
461
|
+
turnUpdate: TurnStreamUpdate,
|
|
462
|
+
content: readonly AssistantContentPart[],
|
|
463
|
+
record: Pick<Turn, "state">,
|
|
464
|
+
): { status: MessageStatus; custom: Record<string, unknown> } {
|
|
465
|
+
let status = turnUpdate.status ?? assistantStatusFromTurnState(record.state);
|
|
466
|
+
let custom = { ...(turnUpdate.metadata?.custom ?? {}) };
|
|
467
|
+
|
|
468
|
+
if (
|
|
469
|
+
status.type === "requires-action" &&
|
|
470
|
+
status.reason === "tool-calls" &&
|
|
471
|
+
!contentHasPendingRequiredActions(content)
|
|
472
|
+
) {
|
|
473
|
+
status = assistantStatusFromTurnState(record.state);
|
|
474
|
+
const {
|
|
475
|
+
[TOOL_APPROVAL_THREAD_ID_CUSTOM_KEY]: _approvalThreadId,
|
|
476
|
+
[TOOL_RESPONSE_THREAD_ID_CUSTOM_KEY]: _responseThreadId,
|
|
477
|
+
...restCustom
|
|
478
|
+
} = custom;
|
|
479
|
+
custom = restCustom;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
return { status, custom };
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function projectActiveStreamUpdate(snapshot: SessionSnapshot): TurnStreamUpdate {
|
|
486
|
+
const activeStream = snapshot.activeStream;
|
|
487
|
+
if (activeStream == null) {
|
|
488
|
+
throw new Error("projectActiveStreamUpdate requires an active stream");
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const hasStagedOverlay =
|
|
492
|
+
snapshot.requiredActions.approvals.size > 0 ||
|
|
493
|
+
snapshot.requiredActions.toolResponses.size > 0;
|
|
494
|
+
|
|
495
|
+
// While the user has staged a response locally, keep the paused stream update
|
|
496
|
+
// so required-action collection can pair interrupt + result before resume.
|
|
497
|
+
if (hasStagedOverlay) {
|
|
498
|
+
return activeStream.update;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const baseline =
|
|
502
|
+
snapshot.groupRootBaseline ?? computeGroupRootBaseline(snapshot.turns);
|
|
503
|
+
const rootModelMessageIds = rootModelMessageIdsSinceBaseline(
|
|
504
|
+
snapshot.fold,
|
|
505
|
+
baseline,
|
|
506
|
+
);
|
|
507
|
+
|
|
508
|
+
const foldContent = buildRootAssistantContentForIds(
|
|
509
|
+
snapshot.fold,
|
|
510
|
+
rootModelMessageIds,
|
|
511
|
+
);
|
|
512
|
+
const content =
|
|
513
|
+
foldContent.length > 0 ? foldContent : activeStream.update.content;
|
|
514
|
+
|
|
515
|
+
const turnRecord = snapshot.turns.find((turn) => turn.id === activeStream.turnId);
|
|
516
|
+
const turnLike = turnRecord ?? snapshot.runningTurn;
|
|
517
|
+
|
|
518
|
+
const rebuilt =
|
|
519
|
+
turnLike != null
|
|
520
|
+
? buildTurnUpdateFromFold(snapshot.fold, turnLike, rootModelMessageIds)
|
|
521
|
+
: { content };
|
|
522
|
+
|
|
523
|
+
return {
|
|
524
|
+
...activeStream.update,
|
|
525
|
+
content,
|
|
526
|
+
metadata: rebuilt.metadata ?? activeStream.update.metadata,
|
|
527
|
+
status: rebuilt.status ?? activeStream.update.status,
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function applyRequiredActionsOverlayToMessages(
|
|
532
|
+
messages: ThreadMessage[],
|
|
533
|
+
overlay: RequiredActionsOverlay,
|
|
534
|
+
): ThreadMessage[] {
|
|
535
|
+
if (overlay.approvals.size === 0 && overlay.toolResponses.size === 0) {
|
|
536
|
+
return messages;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
return messages.map((message) => {
|
|
540
|
+
if (message.role !== "assistant") {
|
|
541
|
+
return message;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
let { content } = message;
|
|
545
|
+
if (overlay.approvals.size > 0) {
|
|
546
|
+
content = applyApprovalDecisionsToContent(content, overlay.approvals);
|
|
547
|
+
}
|
|
548
|
+
if (overlay.toolResponses.size > 0) {
|
|
549
|
+
content = applyStagedResponsesToContent(content, overlay.toolResponses);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
if (content === message.content) {
|
|
553
|
+
return message;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
return { ...message, content: [...content] };
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function projectHistoryTurns(
|
|
561
|
+
snapshot: SessionSnapshot,
|
|
562
|
+
options?: ProjectSessionMessagesOptions,
|
|
563
|
+
): ThreadMessage[] {
|
|
564
|
+
const messages: ThreadMessage[] = [];
|
|
565
|
+
let lastAssistantIndex: number | undefined;
|
|
566
|
+
let groupRootIds: string[] = [];
|
|
567
|
+
|
|
568
|
+
for (let turnIndex = 0; turnIndex < snapshot.turns.length; turnIndex++) {
|
|
569
|
+
const record = snapshot.turns[turnIndex]!;
|
|
570
|
+
const turnRootIds = record.rootModelMessageIds ?? [];
|
|
571
|
+
|
|
572
|
+
if (record.userText) {
|
|
573
|
+
groupRootIds = [...turnRootIds];
|
|
574
|
+
} else {
|
|
575
|
+
groupRootIds = [...groupRootIds, ...turnRootIds];
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const turnUpdate = buildTurnUpdateFromFold(snapshot.fold, record, groupRootIds);
|
|
579
|
+
let content = turnUpdate.content;
|
|
580
|
+
|
|
581
|
+
const subsequentDecisions = collectSubsequentApprovalDecisions(
|
|
582
|
+
snapshot.turns,
|
|
583
|
+
turnIndex,
|
|
584
|
+
);
|
|
585
|
+
if (subsequentDecisions.size > 0) {
|
|
586
|
+
content = applyApprovalDecisionsToContent(content, subsequentDecisions);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const subsequentResponses = collectSubsequentToolResponses(
|
|
590
|
+
snapshot.turns,
|
|
591
|
+
turnIndex,
|
|
592
|
+
);
|
|
593
|
+
if (subsequentResponses.size > 0) {
|
|
594
|
+
content = applyStagedResponsesToContent(content, subsequentResponses);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const currentResponses = collectToolResponsesFromTurnInput(record.input);
|
|
598
|
+
if (currentResponses.size > 0) {
|
|
599
|
+
content = applyStagedResponsesToContent(content, currentResponses);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const currentDecisions = collectApprovalDecisionsFromTurnInput(record.input);
|
|
603
|
+
if (currentDecisions.size > 0) {
|
|
604
|
+
content = applyApprovalDecisionsToContent(content, currentDecisions);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
const { status, custom: baseCustom } = resolveProjectedRequiredActionState(
|
|
608
|
+
turnUpdate,
|
|
609
|
+
content,
|
|
610
|
+
record,
|
|
611
|
+
);
|
|
612
|
+
const custom =
|
|
613
|
+
record.sandboxId != null ? { ...baseCustom, sandboxId: record.sandboxId } : baseCustom;
|
|
614
|
+
|
|
615
|
+
if (record.userText) {
|
|
616
|
+
messages.push(
|
|
617
|
+
buildUserMessageFromTurnInput(
|
|
618
|
+
record.id,
|
|
619
|
+
record.input,
|
|
620
|
+
record.createdAt,
|
|
621
|
+
options,
|
|
622
|
+
),
|
|
623
|
+
);
|
|
624
|
+
|
|
625
|
+
if (record.state.status === "running") {
|
|
626
|
+
if (content.length > 0) {
|
|
627
|
+
messages.push(
|
|
628
|
+
buildAssistantMessage(
|
|
629
|
+
record.id,
|
|
630
|
+
content,
|
|
631
|
+
record.createdAt,
|
|
632
|
+
status,
|
|
633
|
+
custom,
|
|
634
|
+
options,
|
|
635
|
+
),
|
|
636
|
+
);
|
|
637
|
+
lastAssistantIndex = messages.length - 1;
|
|
638
|
+
}
|
|
639
|
+
break;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
if (content.length > 0) {
|
|
643
|
+
messages.push(
|
|
644
|
+
buildAssistantMessage(
|
|
645
|
+
record.id,
|
|
646
|
+
content,
|
|
647
|
+
record.createdAt,
|
|
648
|
+
status,
|
|
649
|
+
custom,
|
|
650
|
+
options,
|
|
651
|
+
),
|
|
652
|
+
);
|
|
653
|
+
lastAssistantIndex = messages.length - 1;
|
|
654
|
+
}
|
|
655
|
+
} else if (content.length > 0 && lastAssistantIndex != null) {
|
|
656
|
+
if (record.state.status === "running") {
|
|
657
|
+
break;
|
|
658
|
+
}
|
|
659
|
+
const existing = messages[lastAssistantIndex] as Extract<
|
|
660
|
+
ThreadMessage,
|
|
661
|
+
{ role: "assistant" }
|
|
662
|
+
>;
|
|
663
|
+
messages[lastAssistantIndex] = {
|
|
664
|
+
...existing,
|
|
665
|
+
content,
|
|
666
|
+
status,
|
|
667
|
+
metadata: {
|
|
668
|
+
...existing.metadata,
|
|
669
|
+
custom,
|
|
670
|
+
},
|
|
671
|
+
};
|
|
672
|
+
} else if (record.state.status === "running") {
|
|
673
|
+
break;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
return messages;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
export function projectSessionMessages(
|
|
681
|
+
snapshot: SessionSnapshot,
|
|
682
|
+
options?: ProjectSessionMessagesOptions,
|
|
683
|
+
): ThreadMessage[] {
|
|
684
|
+
let messages = projectHistoryTurns(snapshot, options);
|
|
685
|
+
|
|
686
|
+
if (snapshot.pendingUser != null) {
|
|
687
|
+
messages.push(
|
|
688
|
+
buildUserMessageFromTurnInput(
|
|
689
|
+
snapshot.pendingUser.turnId,
|
|
690
|
+
[{ type: "user.message", content: snapshot.pendingUser.content }],
|
|
691
|
+
snapshot.pendingUser.createdAt,
|
|
692
|
+
options,
|
|
693
|
+
),
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
if (snapshot.activeStream != null) {
|
|
698
|
+
const { turnId, update, isContinuation, streamComplete } =
|
|
699
|
+
snapshot.activeStream;
|
|
700
|
+
const resolvedUpdate =
|
|
701
|
+
streamComplete === true ? projectActiveStreamUpdate(snapshot) : update;
|
|
702
|
+
|
|
703
|
+
const last = messages.at(-1);
|
|
704
|
+
const existingAssistant =
|
|
705
|
+
isContinuation && last?.role === "assistant" ? last : undefined;
|
|
706
|
+
let assistantMessage = turnStreamUpdateToAssistantMessage(
|
|
707
|
+
turnId,
|
|
708
|
+
resolvedUpdate,
|
|
709
|
+
existingAssistant,
|
|
710
|
+
options,
|
|
711
|
+
);
|
|
712
|
+
if (streamComplete === true && resolvedUpdate.status == null) {
|
|
713
|
+
assistantMessage = {
|
|
714
|
+
...assistantMessage,
|
|
715
|
+
status: { type: "complete", reason: "stop" },
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
if (isContinuation && last?.role === "assistant") {
|
|
720
|
+
messages = [...messages.slice(0, -1), assistantMessage];
|
|
721
|
+
} else {
|
|
722
|
+
messages = [...messages, assistantMessage];
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
return applyRequiredActionsOverlayToMessages(
|
|
727
|
+
messages,
|
|
728
|
+
snapshot.requiredActions,
|
|
729
|
+
);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
const DEFAULT_LIST_EVENTS_CONCURRENCY = 5;
|
|
733
|
+
|
|
734
|
+
function ingestTurnsIntoSnapshot(
|
|
735
|
+
snapshot: SessionSnapshot,
|
|
736
|
+
turns: Turn[],
|
|
737
|
+
eventArrays: TurnEvent[][],
|
|
738
|
+
): Turn | undefined {
|
|
739
|
+
let runningTurn: Turn | undefined;
|
|
740
|
+
|
|
741
|
+
for (let i = 0; i < turns.length; i++) {
|
|
742
|
+
const turn = turns[i]!;
|
|
743
|
+
const rootBucket = snapshot.fold.threads.get(ROOT_THREAD_ID);
|
|
744
|
+
const beforeCount = rootBucket?.modelMessageIds.length ?? 0;
|
|
745
|
+
|
|
746
|
+
ingestCollectedEventsIntoFold(snapshot.fold, eventArrays[i] ?? []);
|
|
747
|
+
applyUserToolResponsesToFold(snapshot.fold, turn.input ?? []);
|
|
748
|
+
|
|
749
|
+
const afterBucket = snapshot.fold.threads.get(ROOT_THREAD_ID);
|
|
750
|
+
const rootModelMessageIds = (afterBucket?.modelMessageIds ?? []).slice(
|
|
751
|
+
beforeCount,
|
|
752
|
+
);
|
|
753
|
+
|
|
754
|
+
const sandboxEvent = (eventArrays[i] ?? []).find(
|
|
755
|
+
(event): event is Extract<TurnEvent, { type: "sandbox.created" }> =>
|
|
756
|
+
event.type === "sandbox.created",
|
|
757
|
+
);
|
|
758
|
+
|
|
759
|
+
snapshot.turns.push({
|
|
760
|
+
...turnToSessionRecord(turn),
|
|
761
|
+
rootModelMessageIds,
|
|
762
|
+
...(sandboxEvent != null ? { sandboxId: sandboxEvent.sandboxId } : {}),
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
if (turn.state.status === "running") {
|
|
766
|
+
runningTurn = turn;
|
|
767
|
+
break;
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
return runningTurn;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
export async function buildSnapshotFromSession(
|
|
775
|
+
session: AgentSession,
|
|
776
|
+
concurrency: number = DEFAULT_LIST_EVENTS_CONCURRENCY,
|
|
777
|
+
): Promise<SessionSnapshot> {
|
|
778
|
+
const turns: Turn[] = [];
|
|
779
|
+
for await (const turn of await session.listTurns()) {
|
|
780
|
+
turns.push(turn);
|
|
781
|
+
}
|
|
782
|
+
turns.reverse();
|
|
783
|
+
|
|
784
|
+
const eventArrays = await fetchAllTurnEventsWithConcurrency(turns, concurrency);
|
|
785
|
+
|
|
786
|
+
const snapshot = createEmptySessionSnapshot();
|
|
787
|
+
const runningTurn = ingestTurnsIntoSnapshot(snapshot, turns, eventArrays);
|
|
788
|
+
|
|
789
|
+
return replaceSessionSnapshot(snapshot, {
|
|
790
|
+
...(runningTurn != null
|
|
791
|
+
? {
|
|
792
|
+
runningTurn,
|
|
793
|
+
unstable_resume: true as const,
|
|
794
|
+
groupRootBaseline: computeGroupRootBaseline(snapshot.turns),
|
|
795
|
+
}
|
|
796
|
+
: {}),
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/** Rebuilds session state from turns strictly before `beforeTurnId` (excludes that turn). */
|
|
801
|
+
export async function buildSnapshotBeforeTurn(
|
|
802
|
+
session: AgentSession,
|
|
803
|
+
beforeTurnId: string,
|
|
804
|
+
concurrency: number = DEFAULT_LIST_EVENTS_CONCURRENCY,
|
|
805
|
+
): Promise<SessionSnapshot> {
|
|
806
|
+
const turns: Turn[] = [];
|
|
807
|
+
for await (const turn of await session.listTurns()) {
|
|
808
|
+
turns.push(turn);
|
|
809
|
+
}
|
|
810
|
+
turns.reverse();
|
|
811
|
+
|
|
812
|
+
const beforeIndex = turns.findIndex((turn) => turn.id === beforeTurnId);
|
|
813
|
+
if (beforeIndex === -1) {
|
|
814
|
+
throw new Error(`Turn ${beforeTurnId} not found in session`);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
return buildSnapshotBeforeTurnIndex(session, beforeIndex, concurrency, turns);
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/** Rebuilds session state from the first `turnIndex` gateway turns (excludes that turn). */
|
|
821
|
+
export async function buildSnapshotBeforeTurnIndex(
|
|
822
|
+
session: AgentSession,
|
|
823
|
+
turnIndex: number,
|
|
824
|
+
concurrency: number = DEFAULT_LIST_EVENTS_CONCURRENCY,
|
|
825
|
+
orderedTurns?: Turn[],
|
|
826
|
+
): Promise<SessionSnapshot> {
|
|
827
|
+
if (turnIndex <= 0) {
|
|
828
|
+
return createEmptySessionSnapshot();
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
const turns = orderedTurns ?? (await listSessionTurnsOrdered(session));
|
|
832
|
+
const turnsToInclude = turns.slice(0, turnIndex);
|
|
833
|
+
if (turnsToInclude.length === 0) {
|
|
834
|
+
return createEmptySessionSnapshot();
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
const eventArrays = await fetchAllTurnEventsWithConcurrency(
|
|
838
|
+
turnsToInclude,
|
|
839
|
+
concurrency,
|
|
840
|
+
);
|
|
841
|
+
const snapshot = createEmptySessionSnapshot();
|
|
842
|
+
ingestTurnsIntoSnapshot(snapshot, turnsToInclude, eventArrays);
|
|
843
|
+
return snapshot;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/** Gateway turn id to branch from when resubmitting at `turnIndex` (null for first turn). */
|
|
847
|
+
export async function resolveGatewayBranchPreviousTurnId(
|
|
848
|
+
session: AgentSession,
|
|
849
|
+
turnIndex: number,
|
|
850
|
+
orderedTurns?: Turn[],
|
|
851
|
+
): Promise<string | null> {
|
|
852
|
+
if (turnIndex <= 0) {
|
|
853
|
+
return null;
|
|
854
|
+
}
|
|
855
|
+
const turns = orderedTurns ?? (await listSessionTurnsOrdered(session));
|
|
856
|
+
return turns[turnIndex - 1]?.id ?? null;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
async function listSessionTurnsOrdered(session: AgentSession): Promise<Turn[]> {
|
|
860
|
+
const turns: Turn[] = [];
|
|
861
|
+
for await (const turn of await session.listTurns()) {
|
|
862
|
+
turns.push(turn);
|
|
863
|
+
}
|
|
864
|
+
turns.reverse();
|
|
865
|
+
return turns;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
export async function buildTurnAssistantContent(
|
|
869
|
+
turn: Pick<Turn, "listEvents" | "state">,
|
|
870
|
+
foldState?: PeerThreadFoldState,
|
|
871
|
+
): Promise<AssistantContentPart[]> {
|
|
872
|
+
const state = foldState ?? new PeerThreadFoldState();
|
|
873
|
+
const beforeCount =
|
|
874
|
+
state.threads.get(ROOT_THREAD_ID)?.modelMessageIds.length ?? 0;
|
|
875
|
+
await ingestTurnEventsIntoFold(state, turn);
|
|
876
|
+
const afterIds = state.threads.get(ROOT_THREAD_ID)?.modelMessageIds ?? [];
|
|
877
|
+
const rootModelMessageIds = afterIds.slice(beforeCount);
|
|
878
|
+
return buildTurnUpdateFromFold(state, turn, rootModelMessageIds).content;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
export async function convertTurnsToThreadMessages(
|
|
882
|
+
session: AgentSession,
|
|
883
|
+
): Promise<ConvertTurnsResult> {
|
|
884
|
+
const snapshot = await buildSnapshotFromSession(session);
|
|
885
|
+
const messages = projectSessionMessages(snapshot);
|
|
886
|
+
|
|
887
|
+
return {
|
|
888
|
+
messages,
|
|
889
|
+
foldState: snapshot.fold,
|
|
890
|
+
...(snapshot.runningTurn != null
|
|
891
|
+
? {
|
|
892
|
+
runningTurn: snapshot.runningTurn,
|
|
893
|
+
unstable_resume: true as const,
|
|
894
|
+
}
|
|
895
|
+
: {}),
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
export function getTurnMessageContent(message: AppendMessage): string {
|
|
900
|
+
const parts: string[] = [];
|
|
901
|
+
for (const part of message.content) {
|
|
902
|
+
if (part.type === "text") {
|
|
903
|
+
parts.push(part.text);
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
const text = parts.join("\n").trim();
|
|
907
|
+
if (!text) {
|
|
908
|
+
throw new Error("User message must contain text content.");
|
|
909
|
+
}
|
|
910
|
+
return text;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/**
|
|
914
|
+
* Gateway `user.message` content (string for text-only, or text/file parts).
|
|
915
|
+
* Derived from SDK `TurnInputItem` so it tracks API changes.
|
|
916
|
+
*/
|
|
917
|
+
export type UserMessageContent = Extract<
|
|
918
|
+
TurnInputItem,
|
|
919
|
+
{ type: "user.message" }
|
|
920
|
+
>["content"];
|
|
921
|
+
|
|
922
|
+
type UserMessageContentItem = Exclude<UserMessageContent, string>[number];
|
|
923
|
+
type FileContent = Extract<UserMessageContentItem, { type: "file" }>;
|
|
924
|
+
type TextContent = Extract<UserMessageContentItem, { type: "text" }>;
|
|
925
|
+
|
|
926
|
+
function toFileDataUri(data: string, mimeType: string): string {
|
|
927
|
+
// FileContent expects a data URI (`data:<mime>;base64,<payload>`).
|
|
928
|
+
// Attachment adapters typically already produce one via FileReader.readAsDataURL;
|
|
929
|
+
// only wrap bare base64 payloads.
|
|
930
|
+
if (data.startsWith("data:")) {
|
|
931
|
+
return data;
|
|
932
|
+
}
|
|
933
|
+
return `data:${mimeType};base64,${data}`;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
function toFileContent(name: string, data: string, mimeType: string): FileContent {
|
|
937
|
+
return {
|
|
938
|
+
type: "file",
|
|
939
|
+
name,
|
|
940
|
+
data: toFileDataUri(data, mimeType),
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/**
|
|
945
|
+
* Builds the gateway turn input content from a composer message, forwarding
|
|
946
|
+
* attachments as SDK `FileContent` parts. Mirrors how assistant-ui surfaces
|
|
947
|
+
* attachment content on `message.attachments[].content`.
|
|
948
|
+
*/
|
|
949
|
+
export function buildUserMessageContent(message: AppendMessage): UserMessageContent {
|
|
950
|
+
const contentParts = message.content as readonly ThreadUserMessagePart[];
|
|
951
|
+
const inputParts: (ThreadUserMessagePart & { filename?: string })[] = [
|
|
952
|
+
...contentParts,
|
|
953
|
+
...(message.attachments?.flatMap((attachment) =>
|
|
954
|
+
attachment.content.map((part) => ({
|
|
955
|
+
...part,
|
|
956
|
+
filename: attachment.name,
|
|
957
|
+
})),
|
|
958
|
+
) ?? []),
|
|
959
|
+
];
|
|
960
|
+
|
|
961
|
+
const items: UserMessageContentItem[] = [];
|
|
962
|
+
for (const part of inputParts) {
|
|
963
|
+
switch (part.type) {
|
|
964
|
+
case "text":
|
|
965
|
+
if (part.text.trim().length > 0) {
|
|
966
|
+
const textPart: TextContent = { type: "text", text: part.text };
|
|
967
|
+
items.push(textPart);
|
|
968
|
+
}
|
|
969
|
+
break;
|
|
970
|
+
case "image":
|
|
971
|
+
items.push(
|
|
972
|
+
toFileContent(part.filename ?? "image", part.image, "image/png"),
|
|
973
|
+
);
|
|
974
|
+
break;
|
|
975
|
+
case "file":
|
|
976
|
+
items.push(
|
|
977
|
+
toFileContent(
|
|
978
|
+
part.filename ?? "file",
|
|
979
|
+
part.data,
|
|
980
|
+
part.mimeType,
|
|
981
|
+
),
|
|
982
|
+
);
|
|
983
|
+
break;
|
|
984
|
+
default:
|
|
985
|
+
break;
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
const hasFile = items.some((item): item is FileContent => item.type === "file");
|
|
990
|
+
if (!hasFile) {
|
|
991
|
+
// Text-only: keep the string form and the non-empty invariant.
|
|
992
|
+
return getTurnMessageContent(message);
|
|
993
|
+
}
|
|
994
|
+
return items;
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
/** Derives display text from gateway user-message content (drops file parts). */
|
|
998
|
+
export function userMessageContentToText(content: UserMessageContent): string {
|
|
999
|
+
if (typeof content === "string") {
|
|
1000
|
+
return content;
|
|
1001
|
+
}
|
|
1002
|
+
return content
|
|
1003
|
+
.filter(
|
|
1004
|
+
(item): item is TextContent => item.type === "text",
|
|
1005
|
+
)
|
|
1006
|
+
.map((item) => item.text)
|
|
1007
|
+
.join("\n")
|
|
1008
|
+
.trim();
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
/** Strips the `-user` suffix from a projected user message id to recover the turn id. */
|
|
1012
|
+
export function parseTurnIdFromMessageId(messageId: string): string {
|
|
1013
|
+
return messageId.replace(/-user$/, "");
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
/** Parent turn id for branching before `turnId`; `null` when editing the first turn. */
|
|
1017
|
+
export function resolveBranchPreviousTurnId(
|
|
1018
|
+
turns: readonly SessionTurnRecord[],
|
|
1019
|
+
turnId: string,
|
|
1020
|
+
): string | null {
|
|
1021
|
+
const turnIndex = turns.findIndex((turn) => turn.id === turnId);
|
|
1022
|
+
if (turnIndex <= 0) {
|
|
1023
|
+
return null;
|
|
1024
|
+
}
|
|
1025
|
+
return turns[turnIndex - 1]!.id;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/** Extracts edited text from an assistant-ui append message (text parts only). */
|
|
1029
|
+
export function extractEditedText(message: AppendMessage): string {
|
|
1030
|
+
return getTurnMessageContent(message);
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
/** Original gateway user-message content from a turn record (for reset). */
|
|
1034
|
+
export function extractTurnUserMessageContent(
|
|
1035
|
+
input: TurnInputItem[] | undefined,
|
|
1036
|
+
): UserMessageContent {
|
|
1037
|
+
for (const item of input ?? []) {
|
|
1038
|
+
if (item.type === "user.message") {
|
|
1039
|
+
return item.content;
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
return "";
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
/**
|
|
1046
|
+
* Builds resubmit content for a text-only edit: replaces text with `editedText`
|
|
1047
|
+
* while preserving original file parts from the turn record.
|
|
1048
|
+
*/
|
|
1049
|
+
export function buildEditedUserMessageContent(
|
|
1050
|
+
editedText: string,
|
|
1051
|
+
originalInput: TurnInputItem[] | undefined,
|
|
1052
|
+
): UserMessageContent {
|
|
1053
|
+
const fileParts: FileContent[] = [];
|
|
1054
|
+
for (const item of originalInput ?? []) {
|
|
1055
|
+
if (item.type !== "user.message") {
|
|
1056
|
+
continue;
|
|
1057
|
+
}
|
|
1058
|
+
const content = item.content;
|
|
1059
|
+
if (typeof content === "string") {
|
|
1060
|
+
continue;
|
|
1061
|
+
}
|
|
1062
|
+
for (const part of content) {
|
|
1063
|
+
if (part.type === "file") {
|
|
1064
|
+
fileParts.push(part);
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
if (fileParts.length === 0) {
|
|
1070
|
+
return editedText;
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
const items: UserMessageContentItem[] = [];
|
|
1074
|
+
if (editedText.trim().length > 0) {
|
|
1075
|
+
items.push({ type: "text", text: editedText });
|
|
1076
|
+
}
|
|
1077
|
+
items.push(...fileParts);
|
|
1078
|
+
return items;
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
function buildMcpAuthUpdate(
|
|
1082
|
+
pendingMcpAuth: McpAuthRequiredEvent,
|
|
1083
|
+
foldState: PeerThreadFoldState,
|
|
1084
|
+
groupRootBaseline?: readonly string[],
|
|
1085
|
+
): TurnStreamUpdate {
|
|
1086
|
+
const base =
|
|
1087
|
+
groupRootBaseline != null
|
|
1088
|
+
? buildRootAssistantContentForIds(
|
|
1089
|
+
foldState,
|
|
1090
|
+
rootModelMessageIdsSinceBaseline(foldState, groupRootBaseline),
|
|
1091
|
+
)
|
|
1092
|
+
: buildRootAssistantContent(foldState);
|
|
1093
|
+
return {
|
|
1094
|
+
content: [...base, ...buildMcpAuthTextParts(pendingMcpAuth.mcpServers)],
|
|
1095
|
+
status: mcpAuthAssistantStatus(),
|
|
1096
|
+
metadata: { custom: mcpAuthMessageCustom(pendingMcpAuth.mcpServers) },
|
|
1097
|
+
};
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
export async function* streamTurnEvents(
|
|
1101
|
+
stream: AsyncIterable<TurnStreamData>,
|
|
1102
|
+
foldState: PeerThreadFoldState,
|
|
1103
|
+
groupRootBaseline?: readonly string[],
|
|
1104
|
+
): AsyncGenerator<TurnStreamUpdate> {
|
|
1105
|
+
let pendingMcpAuth: McpAuthRequiredEvent | undefined;
|
|
1106
|
+
let sandboxId: string | undefined;
|
|
1107
|
+
let sandboxIdYielded = false;
|
|
1108
|
+
|
|
1109
|
+
const withSandbox = (update: TurnStreamUpdate): TurnStreamUpdate => {
|
|
1110
|
+
if (sandboxId == null) {
|
|
1111
|
+
return update;
|
|
1112
|
+
}
|
|
1113
|
+
sandboxIdYielded = true;
|
|
1114
|
+
return {
|
|
1115
|
+
...update,
|
|
1116
|
+
metadata: { ...update.metadata, custom: { ...update.metadata?.custom, sandboxId } },
|
|
1117
|
+
};
|
|
1118
|
+
};
|
|
1119
|
+
|
|
1120
|
+
const yieldContent = (): AssistantContentPart[] | undefined => {
|
|
1121
|
+
const ids =
|
|
1122
|
+
groupRootBaseline != null
|
|
1123
|
+
? rootModelMessageIdsSinceBaseline(foldState, groupRootBaseline)
|
|
1124
|
+
: foldState.threads.get(ROOT_THREAD_ID)?.modelMessageIds ?? [];
|
|
1125
|
+
const content = buildRootAssistantContentForIds(foldState, ids);
|
|
1126
|
+
return content.length > 0 ? content : undefined;
|
|
1127
|
+
};
|
|
1128
|
+
|
|
1129
|
+
for await (const data of stream) {
|
|
1130
|
+
const event = data.event;
|
|
1131
|
+
|
|
1132
|
+
if (event.type === "sandbox.created") {
|
|
1133
|
+
sandboxId = event.sandboxId;
|
|
1134
|
+
continue;
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
if (event.type === "mcp.auth_required") {
|
|
1138
|
+
pendingMcpAuth = event;
|
|
1139
|
+
continue;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
if (event.type === "turn.done") {
|
|
1143
|
+
if (event.state.status === "error") {
|
|
1144
|
+
throw new Error(event.state.message);
|
|
1145
|
+
}
|
|
1146
|
+
// The turn is logically complete once `turn.done` is observed. The
|
|
1147
|
+
// resumed-turn transport (`subscribeToTurn`) is a reconnectable live
|
|
1148
|
+
// tail and is not guaranteed to close its SSE body right after this
|
|
1149
|
+
// event, so we must stop consuming explicitly rather than waiting
|
|
1150
|
+
// for the underlying stream to end — otherwise `isRunning` never
|
|
1151
|
+
// clears and the composer's cancel/spinner button gets stuck.
|
|
1152
|
+
break;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
if (!ingestStreamEvent(foldState, event)) {
|
|
1156
|
+
continue;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
const content = yieldContent();
|
|
1160
|
+
if (content != null) {
|
|
1161
|
+
yield withSandbox({ content });
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
if (pendingMcpAuth != null) {
|
|
1166
|
+
yield withSandbox(buildMcpAuthUpdate(pendingMcpAuth, foldState, groupRootBaseline));
|
|
1167
|
+
return;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
const approvalThreadId = findFirstPendingApprovalThreadId(foldState);
|
|
1171
|
+
const responseThreadId = findFirstPendingResponseThreadId(foldState);
|
|
1172
|
+
if (approvalThreadId != null || responseThreadId != null) {
|
|
1173
|
+
const custom: Record<string, unknown> = {};
|
|
1174
|
+
if (approvalThreadId != null) {
|
|
1175
|
+
Object.assign(
|
|
1176
|
+
custom,
|
|
1177
|
+
toolApprovalMessageCustom(
|
|
1178
|
+
approvalThreadId === ROOT_THREAD_ID ? ROOT_THREAD_ID : approvalThreadId,
|
|
1179
|
+
),
|
|
1180
|
+
);
|
|
1181
|
+
}
|
|
1182
|
+
if (responseThreadId != null) {
|
|
1183
|
+
Object.assign(
|
|
1184
|
+
custom,
|
|
1185
|
+
toolResponseMessageCustom(
|
|
1186
|
+
responseThreadId === ROOT_THREAD_ID ? ROOT_THREAD_ID : responseThreadId,
|
|
1187
|
+
),
|
|
1188
|
+
);
|
|
1189
|
+
}
|
|
1190
|
+
const ids =
|
|
1191
|
+
groupRootBaseline != null
|
|
1192
|
+
? rootModelMessageIdsSinceBaseline(foldState, groupRootBaseline)
|
|
1193
|
+
: foldState.threads.get(ROOT_THREAD_ID)?.modelMessageIds ?? [];
|
|
1194
|
+
yield withSandbox({
|
|
1195
|
+
content: buildRootAssistantContentForIds(foldState, ids),
|
|
1196
|
+
status:
|
|
1197
|
+
approvalThreadId != null ? toolApprovalStatus() : toolResponseStatus(),
|
|
1198
|
+
metadata: { custom },
|
|
1199
|
+
});
|
|
1200
|
+
return;
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
if (sandboxId != null && !sandboxIdYielded) {
|
|
1204
|
+
const ids =
|
|
1205
|
+
groupRootBaseline != null
|
|
1206
|
+
? rootModelMessageIdsSinceBaseline(foldState, groupRootBaseline)
|
|
1207
|
+
: foldState.threads.get(ROOT_THREAD_ID)?.modelMessageIds ?? [];
|
|
1208
|
+
yield withSandbox({ content: buildRootAssistantContentForIds(foldState, ids) });
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
export function turnStreamUpdateToAssistantMessage(
|
|
1213
|
+
turnId: string,
|
|
1214
|
+
update: TurnStreamUpdate,
|
|
1215
|
+
existing?: ThreadMessage,
|
|
1216
|
+
options?: ProjectSessionMessagesOptions,
|
|
1217
|
+
): ThreadMessage {
|
|
1218
|
+
const id =
|
|
1219
|
+
existing?.role === "assistant"
|
|
1220
|
+
? existing.id
|
|
1221
|
+
: `${turnId}-assistant`;
|
|
1222
|
+
const fallbackCreatedAt = existing?.createdAt ?? new Date();
|
|
1223
|
+
return {
|
|
1224
|
+
id,
|
|
1225
|
+
role: "assistant",
|
|
1226
|
+
content: update.content,
|
|
1227
|
+
status: update.status ?? { type: "running" },
|
|
1228
|
+
createdAt: resolveCreatedAt(id, fallbackCreatedAt, options),
|
|
1229
|
+
metadata: {
|
|
1230
|
+
unstable_state: null,
|
|
1231
|
+
unstable_annotations: [],
|
|
1232
|
+
unstable_data: [],
|
|
1233
|
+
steps: [],
|
|
1234
|
+
custom: update.metadata?.custom ?? {},
|
|
1235
|
+
},
|
|
1236
|
+
};
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
export function repositoryItemsFromMessages(
|
|
1240
|
+
messages: readonly ThreadMessage[],
|
|
1241
|
+
): ExportedMessageRepositoryItem[] {
|
|
1242
|
+
const items: ExportedMessageRepositoryItem[] = [];
|
|
1243
|
+
let parentId: string | null = null;
|
|
1244
|
+
for (const message of messages) {
|
|
1245
|
+
items.push({ parentId, message });
|
|
1246
|
+
parentId = message.id;
|
|
1247
|
+
}
|
|
1248
|
+
return items;
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
export type { SessionSnapshot, ProjectSessionMessagesOptions } from "./sessionSnapshot.js";
|