@assistant-ui/eve 0.0.23 → 0.0.25

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 (37) hide show
  1. package/dist/convertEveMessages.d.ts +40 -51
  2. package/dist/convertEveMessages.d.ts.map +1 -1
  3. package/dist/convertEveMessages.js +151 -122
  4. package/dist/convertEveMessages.js.map +1 -1
  5. package/dist/deriveCreatedAt.d.ts +9 -9
  6. package/dist/deriveCreatedAt.d.ts.map +1 -1
  7. package/dist/deriveCreatedAt.js +25 -34
  8. package/dist/deriveCreatedAt.js.map +1 -1
  9. package/dist/eveCloudSessions.js +1 -2
  10. package/dist/eveExtras.d.ts +4 -2
  11. package/dist/eveExtras.d.ts.map +1 -1
  12. package/dist/eveExtras.js +1 -2
  13. package/dist/eveExtras.js.map +1 -1
  14. package/dist/hooks.d.ts +4 -3
  15. package/dist/hooks.d.ts.map +1 -1
  16. package/dist/hooks.js +5 -5
  17. package/dist/hooks.js.map +1 -1
  18. package/dist/sdkIdentity.js +2 -3
  19. package/dist/useEveAgentRuntime.d.ts +3 -5
  20. package/dist/useEveAgentRuntime.d.ts.map +1 -1
  21. package/dist/useEveAgentRuntime.js +34 -23
  22. package/dist/useEveAgentRuntime.js.map +1 -1
  23. package/package.json +10 -10
  24. package/src/convertEveMessages.test.ts +940 -775
  25. package/src/convertEveMessages.ts +290 -233
  26. package/src/deriveCreatedAt.test.ts +90 -88
  27. package/src/deriveCreatedAt.ts +32 -36
  28. package/src/eveExtras.ts +4 -4
  29. package/src/hooks.ts +4 -3
  30. package/src/testUtils.ts +189 -8
  31. package/src/useEveAgentRuntime.cloud.test.tsx +105 -136
  32. package/src/useEveAgentRuntime.eveResume.test.tsx +22 -104
  33. package/src/useEveAgentRuntime.fast-refresh.test.tsx +195 -37
  34. package/src/useEveAgentRuntime.interruptedTurn.test.tsx +86 -40
  35. package/src/useEveAgentRuntime.refetch.test.tsx +19 -58
  36. package/src/useEveAgentRuntime.test.tsx +372 -291
  37. package/src/useEveAgentRuntime.ts +100 -113
@@ -25,16 +25,18 @@ import {
25
25
  toMediaWireUrl,
26
26
  } from "@assistant-ui/core/internal";
27
27
  import type {
28
- EveAuthorizationOutcome,
29
- EveAuthorizationPart,
30
- EveDynamicToolPart,
31
28
  EveMessage,
32
- EveMessageData,
33
- EveMessageInputRequest,
34
29
  EveMessagePart,
30
+ EveToolPart,
31
+ Interaction,
35
32
  } from "eve/react";
36
- import type { InputResponse } from "eve/client";
37
- import type { MessageStreamEvent } from "eve/client";
33
+ import type { ConversationState, SessionStreamEvent } from "eve/client";
34
+ import {
35
+ APPROVE_OPTION_ID,
36
+ DENY_OPTION_ID,
37
+ type Answer,
38
+ type InteractionOutcome,
39
+ } from "eve/events";
38
40
 
39
41
  const ASSISTANT_COMPLETE_STATUS = {
40
42
  type: "complete",
@@ -57,8 +59,8 @@ const USER_FALLBACK_STATUS = {
57
59
 
58
60
  export type ConvertEveMessagesOptions = {
59
61
  /**
60
- * Marks the last assistant message as running while Eve is submitting or
61
- * streaming. When omitted, a message carrying Eve's `"streaming"` marker is
62
+ * Marks the last assistant message as running while Eve has a turn in
63
+ * flight. When omitted, a message carrying Eve's `"streaming"` marker is
62
64
  * treated as running; pass `false` to settle interrupted messages to a
63
65
  * terminal status.
64
66
  */
@@ -69,11 +71,11 @@ export type ConvertEveMessagesOptions = {
69
71
  readonly error?: unknown;
70
72
  readonly getCreatedAt?: ((message: EveMessage) => Date) | undefined;
71
73
  /**
72
- * The session's stream events. A `turn.failed` or `turn.cancelled` event
73
- * marks its turn's assistant message as incomplete, with a failure's code
74
- * and message as its error.
74
+ * The session's stream events. A failed or cancelled turn marks its
75
+ * assistant message as incomplete, with a failure's code and message as its
76
+ * error.
75
77
  */
76
- readonly events?: readonly MessageStreamEvent[] | undefined;
78
+ readonly events?: readonly SessionStreamEvent[] | undefined;
77
79
  };
78
80
 
79
81
  const isRecord = (value: unknown): value is Record<string, unknown> =>
@@ -109,25 +111,33 @@ const toMessageStatus = (
109
111
  if (interrupted) return interrupted;
110
112
 
111
113
  const isLast = index === messages.length - 1;
112
- const hasPendingApproval = message.parts.some(
113
- (part) =>
114
- part.type === "dynamic-tool" && part.state === "approval-requested",
114
+ const interactions = message.parts.flatMap((part) =>
115
+ part.type === "interaction"
116
+ ? [part]
117
+ : part.type === "tool" && part.interaction
118
+ ? [part.interaction]
119
+ : [],
120
+ );
121
+ const hasPendingApproval = interactions.some(
122
+ (interaction) =>
123
+ interaction.status === "open" && interaction.request.kind !== "sign-in",
115
124
  );
116
125
  // Scoped to the stale-marker case so a terminalized or errored turn keeps its
117
126
  // own status; the hold only claims the fall-through that reads as cancelled.
118
127
  const hasPendingAuthorization =
119
128
  message.metadata?.status === "streaming" &&
120
129
  (!isLast || options.error === undefined) &&
121
- message.parts.some(
122
- (part) => part.type === "authorization" && part.state === "required",
130
+ interactions.some(
131
+ (interaction) =>
132
+ interaction.status === "open" && interaction.request.kind === "sign-in",
123
133
  );
124
134
 
125
135
  if (hasPendingApproval) {
126
136
  return { type: "requires-action", reason: "tool-calls" };
127
137
  }
128
138
 
129
- // A connector authorization is answered outside the thread, so the turn is
130
- // held on external input rather than on a tool call awaiting a result.
139
+ // A sign-in is answered outside the thread, so the turn is held on external
140
+ // input rather than on a tool call awaiting a result.
131
141
  if (hasPendingAuthorization) {
132
142
  return { type: "requires-action", reason: "interrupt" };
133
143
  }
@@ -139,8 +149,6 @@ const toMessageStatus = (
139
149
  if (isLast && options.isRunning === true) {
140
150
  return ASSISTANT_RUNNING_STATUS;
141
151
  }
142
-
143
- // Some eve versions leave the streaming marker after cancellation or failure.
144
152
  if (message.metadata?.status === "streaming") {
145
153
  if (options.isRunning === undefined) {
146
154
  return ASSISTANT_RUNNING_STATUS;
@@ -159,7 +167,7 @@ const toMessageStatus = (
159
167
  };
160
168
 
161
169
  const toolApprovalOptionsFromInputRequest = (
162
- inputRequest: EveMessageInputRequest | undefined,
170
+ inputRequest: Interaction["request"] | undefined,
163
171
  ): readonly ToolApprovalOption[] | undefined => {
164
172
  const options = inputRequest?.options;
165
173
  if (!options || options.length === 0) return undefined;
@@ -167,9 +175,9 @@ const toolApprovalOptionsFromInputRequest = (
167
175
  return options.map((option) => ({
168
176
  id: option.id,
169
177
  kind:
170
- option.id === "approve"
178
+ option.id === APPROVE_OPTION_ID
171
179
  ? "allow-once"
172
- : option.id === "cancel"
180
+ : option.id === DENY_OPTION_ID
173
181
  ? "reject-once"
174
182
  : `_${option.id}`,
175
183
  ...(option.label && { label: option.label }),
@@ -178,16 +186,16 @@ const toolApprovalOptionsFromInputRequest = (
178
186
  };
179
187
 
180
188
  /**
181
- * Eve resolves a request the moment any response for it arrives, so the answer
182
- * shapes it accepts decide both what the renderer may offer and what
189
+ * Eve resolves an interaction the moment any answer for it arrives, so the
190
+ * answer shapes it accepts decide both what the renderer may offer and what
183
191
  * {@link toEveInputResponse} may submit. A request with no options and no
184
192
  * `display` takes a typed answer, which is why the absence of both is not the
185
193
  * same as a confirmation.
186
194
  */
187
- const isDroppedOptionApproval = (inputRequest: EveMessageInputRequest) =>
188
- inputRequest.kind === "tool-approval" && !inputRequest.options?.length;
195
+ const isDroppedOptionApproval = (inputRequest: Interaction["request"]) =>
196
+ inputRequest.kind === "approval" && !inputRequest.options?.length;
189
197
 
190
- const acceptsFreeformAnswer = (inputRequest: EveMessageInputRequest): boolean =>
198
+ const acceptsFreeformAnswer = (inputRequest: Interaction["request"]): boolean =>
191
199
  inputRequest.display !== "confirmation" &&
192
200
  !isDroppedOptionApproval(inputRequest) &&
193
201
  (inputRequest.display === "text" ||
@@ -195,7 +203,7 @@ const acceptsFreeformAnswer = (inputRequest: EveMessageInputRequest): boolean =>
195
203
  !inputRequest.options?.length);
196
204
 
197
205
  const toolApprovalDisplay = (
198
- inputRequest: EveMessageInputRequest,
206
+ inputRequest: Interaction["request"],
199
207
  ): ToolApprovalDisplay => {
200
208
  // The mapper answers this shape through its approve/cancel branch whatever
201
209
  // the request declares, so the projection has to agree: presenting it as a
@@ -210,126 +218,126 @@ const toolApprovalDisplay = (
210
218
  case "text":
211
219
  return "text";
212
220
  default:
213
- if (inputRequest.kind === "tool-approval") return "decision";
221
+ if (inputRequest.kind === "approval") return "decision";
214
222
  return inputRequest.options?.length ? "select" : "text";
215
223
  }
216
224
  };
217
225
 
218
226
  const toApproval = (
219
- part: EveDynamicToolPart,
220
- ): ToolCallMessagePart["approval"] | undefined => {
221
- if (
222
- part.state !== "approval-requested" &&
223
- part.state !== "approval-responded" &&
224
- part.state !== "output-available" &&
225
- part.state !== "output-error" &&
226
- part.state !== "output-denied"
227
- ) {
228
- return undefined;
229
- }
230
-
231
- const approval = part.approval;
232
- if (!approval) return undefined;
233
- const inputRequest = part.toolMetadata?.eve?.inputRequest;
234
- const options = toolApprovalOptionsFromInputRequest(inputRequest);
227
+ interaction: Interaction,
228
+ ): NonNullable<ToolCallMessagePart["approval"]> => {
229
+ const request = interaction.request;
230
+ const options = toolApprovalOptionsFromInputRequest(request);
231
+ const settled = interaction.status === "settled";
232
+ const approved =
233
+ interaction.outcome === "accepted"
234
+ ? true
235
+ : interaction.outcome === "declined"
236
+ ? false
237
+ : undefined;
235
238
 
236
239
  return {
237
- id: approval.id,
238
- ...(inputRequest && {
239
- prompt: inputRequest.prompt,
240
- display: toolApprovalDisplay(inputRequest),
241
- ...(acceptsFreeformAnswer(inputRequest) && { allowFreeform: true }),
242
- }),
243
- ...(approval.approved !== undefined && { approved: approval.approved }),
244
- ...(approval.reason && { reason: approval.reason }),
245
- ...(approval.isAutomatic !== undefined && {
246
- isAutomatic: approval.isAutomatic,
247
- }),
240
+ id: interaction.interactionId,
241
+ prompt: request.prompt,
242
+ display: toolApprovalDisplay(request),
243
+ ...(acceptsFreeformAnswer(request) && { allowFreeform: true }),
244
+ ...(settled && approved !== undefined && { approved }),
245
+ ...(settled &&
246
+ approved === undefined && {
247
+ resolution:
248
+ interaction.outcome === "expired"
249
+ ? ("expired" as const)
250
+ : ("cancelled" as const),
251
+ }),
252
+ ...(interaction.reason && { reason: interaction.reason }),
253
+ ...(settled &&
254
+ interaction.answer?.optionId !== undefined && {
255
+ optionId: interaction.answer.optionId,
256
+ }),
257
+ ...(settled &&
258
+ interaction.answer?.text !== undefined && {
259
+ text: interaction.answer.text,
260
+ }),
248
261
  ...(options && { options }),
249
262
  };
250
263
  };
251
264
 
252
- const toJsonSafeInputRequest = (
253
- inputRequest: EveMessageInputRequest,
254
- ): PartProviderMetadata[string] => ({
255
- requestId: inputRequest.requestId,
256
- prompt: inputRequest.prompt,
257
- kind: inputRequest.kind,
258
- ...(inputRequest.display !== undefined && { display: inputRequest.display }),
259
- ...(inputRequest.allowFreeform !== undefined && {
260
- allowFreeform: inputRequest.allowFreeform,
261
- }),
262
- ...(inputRequest.options && {
263
- options: inputRequest.options.map((option) => ({
264
- id: option.id,
265
- label: option.label,
266
- ...(option.description !== undefined && {
267
- description: option.description,
268
- }),
269
- ...(option.style !== undefined && { style: option.style }),
270
- })),
271
- }),
272
- });
273
-
274
265
  const toToolProviderMetadata = (
275
- part: EveDynamicToolPart,
276
- ): PartProviderMetadata | undefined => {
277
- const inputRequest = part.toolMetadata?.eve?.inputRequest;
278
- if (!inputRequest) return undefined;
279
- return { eve: { inputRequest: toJsonSafeInputRequest(inputRequest) } };
266
+ interaction: Interaction,
267
+ ): PartProviderMetadata => {
268
+ const request = interaction.request;
269
+ return {
270
+ eve: {
271
+ interaction: {
272
+ interactionId: interaction.interactionId,
273
+ prompt: request.prompt,
274
+ kind: request.kind,
275
+ ...(request.display !== undefined && { display: request.display }),
276
+ ...(request.allowFreeform !== undefined && {
277
+ allowFreeform: request.allowFreeform,
278
+ }),
279
+ ...(request.options && {
280
+ options: request.options.map((option) => ({ ...option })),
281
+ }),
282
+ },
283
+ },
284
+ };
280
285
  };
281
286
 
282
- const convertDynamicToolPart = (
283
- part: EveDynamicToolPart,
284
- ): ToolCallMessagePart => {
285
- const approval = toApproval(part);
286
- const providerMetadata = toToolProviderMetadata(part);
287
- const toolCall: ToolCallMessagePart = {
287
+ const convertToolPart = (
288
+ part: EveToolPart,
289
+ ): Omit<ToolCallMessagePart, "args"> &
290
+ Partial<Pick<ToolCallMessagePart, "args">> => {
291
+ const toolCall: ReturnType<typeof convertToolPart> = {
288
292
  type: "tool-call",
289
- toolCallId: part.toolCallId,
290
- toolName: part.toolName,
291
- args: toJsonObject(part.input),
292
- argsText: part.state === "input-streaming" ? "" : stringifyArgs(part.input),
293
- ...(approval && { approval }),
294
- ...(providerMetadata && { providerMetadata }),
293
+ toolCallId: part.callId,
294
+ toolName: part.name,
295
+ ...(part.state !== "streaming" && { args: toJsonObject(part.input) }),
296
+ argsText:
297
+ part.state === "streaming" ? part.inputText : stringifyArgs(part.input),
298
+ ...(part.interaction && {
299
+ approval: toApproval(part.interaction),
300
+ providerMetadata: toToolProviderMetadata(part.interaction),
301
+ }),
295
302
  };
296
303
 
297
304
  switch (part.state) {
298
- case "output-available":
305
+ case "completed":
299
306
  return { ...toolCall, result: part.output };
300
-
301
- case "output-error":
302
- return {
303
- ...toolCall,
304
- result: { error: part.errorText },
305
- isError: true,
306
- };
307
-
308
- case "output-denied":
307
+ case "failed":
308
+ case "rejected":
309
+ case "cancelled":
309
310
  return {
310
311
  ...toolCall,
311
- result: { error: part.approval?.reason ?? "Tool approval denied" },
312
+ result: {
313
+ error:
314
+ part.error?.message ??
315
+ part.interaction?.reason ??
316
+ (part.state === "rejected"
317
+ ? "Tool approval denied"
318
+ : part.state === "cancelled"
319
+ ? "Tool call cancelled"
320
+ : "Tool call failed"),
321
+ },
312
322
  isError: true,
313
323
  };
314
-
315
324
  default:
316
- return toolCall;
325
+ return part.output === undefined
326
+ ? toolCall
327
+ : { ...toolCall, result: part.output, isPreliminary: true };
317
328
  }
318
329
  };
319
330
 
320
331
  /**
321
- * Payload of the `authorization` data part the Eve runtime emits for a
322
- * connector authorization challenge. `state` discriminates a pending challenge
323
- * from a settled one; every other field is present only when Eve projected it.
324
- * This converter drops a `url` whose scheme is not `http(s)`, so a renderer can
325
- * link to it without checking the scheme itself; Eve itself passes through
326
- * whatever the connector supplied. Eve's
327
- * `turnId` and `stepIndex` part identity is omitted: a renderer receives the
328
- * one part it renders, in Eve's own step order, so display never has to
329
- * re-identify a challenge among several.
332
+ * Payload of the `authorization` data part the Eve runtime emits for a sign-in
333
+ * interaction. `state` discriminates a pending sign-in from a settled one;
334
+ * every other field is present only when Eve projected it. This converter
335
+ * drops a `url` whose scheme is not `http(s)`, so a renderer can link to it
336
+ * without checking the scheme itself; Eve itself passes through whatever the
337
+ * connector supplied.
330
338
  */
331
339
  export type EveAuthorizationData = {
332
- readonly state: EveAuthorizationPart["state"];
340
+ readonly state: "required" | "completed";
333
341
  readonly name: string;
334
342
  readonly displayName?: string;
335
343
  readonly description?: string;
@@ -337,37 +345,85 @@ export type EveAuthorizationData = {
337
345
  readonly userCode?: string;
338
346
  readonly instructions?: string;
339
347
  readonly expiresAt?: string;
340
- readonly outcome?: EveAuthorizationOutcome;
348
+ readonly outcome?:
349
+ | "authorized"
350
+ | "declined"
351
+ | "failed"
352
+ | "timed-out"
353
+ | "cancelled";
341
354
  readonly reason?: string;
342
355
  };
343
356
 
357
+ const toAuthorizationOutcome = (
358
+ outcome: InteractionOutcome,
359
+ ): NonNullable<EveAuthorizationData["outcome"]> => {
360
+ switch (outcome) {
361
+ case "accepted":
362
+ return "authorized";
363
+ case "declined":
364
+ case "invalid":
365
+ return "declined";
366
+ case "expired":
367
+ return "timed-out";
368
+ case "failed":
369
+ return "failed";
370
+ default:
371
+ return "cancelled";
372
+ }
373
+ };
374
+
344
375
  const convertAuthorizationPart = (
345
- part: EveAuthorizationPart,
346
- ): DataMessagePart<EveAuthorizationData> => ({
347
- type: "data",
348
- name: "authorization",
349
- data: {
350
- state: part.state,
351
- name: part.name,
352
- ...(part.displayName !== undefined && { displayName: part.displayName }),
353
- ...(part.description !== undefined && { description: part.description }),
354
- ...(part.authorization?.url !== undefined &&
355
- httpUrlPattern.test(part.authorization.url) && {
356
- url: part.authorization.url,
376
+ interaction: Interaction,
377
+ ): DataMessagePart<EveAuthorizationData> => {
378
+ const signIn = interaction.request.signIn;
379
+ return {
380
+ type: "data",
381
+ name: "authorization",
382
+ data: {
383
+ state: interaction.status === "open" ? "required" : "completed",
384
+ name: signIn?.name ?? interaction.request.title ?? "sign-in",
385
+ ...(signIn?.displayName !== undefined && {
386
+ displayName: signIn.displayName,
357
387
  }),
358
- ...(part.authorization?.userCode !== undefined && {
359
- userCode: part.authorization.userCode,
360
- }),
361
- ...(part.authorization?.instructions !== undefined && {
362
- instructions: part.authorization.instructions,
363
- }),
364
- ...(part.authorization?.expiresAt !== undefined && {
365
- expiresAt: part.authorization.expiresAt,
388
+ description: interaction.request.prompt,
389
+ ...(signIn?.url !== undefined &&
390
+ httpUrlPattern.test(signIn.url) && { url: signIn.url }),
391
+ ...(signIn?.userCode !== undefined && { userCode: signIn.userCode }),
392
+ ...(signIn?.instructions !== undefined && {
393
+ instructions: signIn.instructions,
394
+ }),
395
+ ...(signIn?.expiresAt !== undefined && { expiresAt: signIn.expiresAt }),
396
+ ...(interaction.outcome !== undefined && {
397
+ outcome: toAuthorizationOutcome(interaction.outcome),
398
+ }),
399
+ ...(interaction.reason !== undefined && { reason: interaction.reason }),
400
+ },
401
+ };
402
+ };
403
+
404
+ const convertInteractionPart = (
405
+ interaction: Interaction,
406
+ ): ThreadAssistantMessagePart => {
407
+ if (interaction.request.kind === "sign-in")
408
+ return convertAuthorizationPart(interaction);
409
+ const approval = toApproval(interaction);
410
+ return {
411
+ type: "tool-call",
412
+ toolCallId: interaction.interactionId,
413
+ toolName: interaction.call?.name ?? interaction.request.kind,
414
+ args: toJsonObject(interaction.call?.input),
415
+ argsText: stringifyArgs(interaction.call?.input),
416
+ approval,
417
+ providerMetadata: toToolProviderMetadata(interaction),
418
+ ...(interaction.status === "settled" && {
419
+ result: interaction.answer ?? {
420
+ outcome: interaction.outcome,
421
+ reason: interaction.reason,
422
+ },
423
+ ...(interaction.outcome !== "accepted" && { isError: true }),
366
424
  }),
367
- ...(part.outcome !== undefined && { outcome: part.outcome }),
368
- ...(part.reason !== undefined && { reason: part.reason }),
369
- },
370
- });
425
+ };
426
+ };
371
427
 
372
428
  const convertFilePart = (
373
429
  part: Extract<EveMessagePart, { type: "file" }>,
@@ -381,14 +437,6 @@ const convertFilePart = (
381
437
  ...(httpUrlPattern.test(part.url) && { sourceType: "url" as const }),
382
438
  };
383
439
  };
384
-
385
- // Eve's `state` is a run marker: the reducer settles a part to "done" only on
386
- // `reasoning.completed`, `message.completed`, or `turn.cancelled`.
387
- // `emitStreamContent` does not flush leftover reasoning on a tool-call, so a
388
- // non-last reasoning part stays `streaming` until the model stream ends and the
389
- // leftover flush runs. Mapping `streaming` to running would pin that block
390
- // open. Only `done` is trustworthy; an unsettled part falls back to core's
391
- // last-part rule.
392
440
  const partStateToStatus = (
393
441
  state: "done" | "streaming" | undefined,
394
442
  ): MessagePartStreamStatus | undefined =>
@@ -396,7 +444,7 @@ const partStateToStatus = (
396
444
 
397
445
  const convertAssistantPart = (
398
446
  part: EveMessagePart,
399
- ): ThreadAssistantMessagePart | null => {
447
+ ): ThreadAssistantMessagePart | ReturnType<typeof convertToolPart> | null => {
400
448
  switch (part.type) {
401
449
  case "text":
402
450
  case "reasoning": {
@@ -408,14 +456,11 @@ const convertAssistantPart = (
408
456
  };
409
457
  }
410
458
 
411
- case "step-start":
412
- return null;
459
+ case "tool":
460
+ return convertToolPart(part);
413
461
 
414
- case "dynamic-tool":
415
- return convertDynamicToolPart(part);
416
-
417
- case "authorization":
418
- return convertAuthorizationPart(part);
462
+ case "interaction":
463
+ return convertInteractionPart(part);
419
464
 
420
465
  case "file":
421
466
  return convertFilePart(part);
@@ -432,9 +477,6 @@ const convertUserPart = (
432
477
  case "text":
433
478
  return { type: "text", text: part.text };
434
479
 
435
- case "file":
436
- return convertFilePart(part);
437
-
438
480
  default:
439
481
  return null;
440
482
  }
@@ -442,9 +484,12 @@ const convertUserPart = (
442
484
 
443
485
  const toUserContent = (
444
486
  parts: readonly EveMessagePart[],
487
+ attachments: readonly CompleteAttachment[],
445
488
  ): readonly ThreadUserMessagePart[] => {
446
489
  const content = parts.map(convertUserPart).filter((part) => part !== null);
447
- return content.length > 0 ? content : [{ type: "text", text: "" }];
490
+ return content.length > 0 || attachments.length > 0
491
+ ? content
492
+ : [{ type: "text", text: "" }];
448
493
  };
449
494
 
450
495
  const toUserAttachments = (
@@ -508,14 +553,16 @@ const convertEveMessageWithInterruptions = (
508
553
  },
509
554
  };
510
555
 
556
+ const attachments =
557
+ message.role === "user" ? toUserAttachments(message.parts) : [];
511
558
  const like: ThreadMessageLike =
512
559
  message.role === "user"
513
560
  ? {
514
561
  role: "user",
515
562
  id: message.id,
516
563
  createdAt,
517
- content: toUserContent(message.parts),
518
- attachments: toUserAttachments(message.parts),
564
+ content: toUserContent(message.parts, attachments),
565
+ attachments,
519
566
  metadata,
520
567
  }
521
568
  : {
@@ -536,22 +583,18 @@ const convertEveMessageWithInterruptions = (
536
583
  };
537
584
 
538
585
  export type InterruptedTurnEventCache = {
539
- lastEvents: readonly MessageStreamEvent[];
540
- interruptions: readonly MessageStreamEvent[];
586
+ lastEvents: readonly SessionStreamEvent[];
587
+ interruptions: readonly SessionStreamEvent[];
541
588
  };
542
589
 
543
- const isTurnInterruption = (event: MessageStreamEvent) =>
544
- event.type === "turn.failed" || event.type === "turn.cancelled";
590
+ const isTurnInterruption = (event: SessionStreamEvent) =>
591
+ (event.type === "turn.settled" && event.data.outcome !== "completed") ||
592
+ (event.type === "session.ended" && event.data.outcome === "failed");
545
593
 
546
- /**
547
- * Collects the `turn.failed` and `turn.cancelled` events of an append-only
548
- * event log, scanning only the events appended since the cached call. The
549
- * returned array keeps its identity until a new interruption arrives.
550
- */
551
594
  export const collectInterruptedTurnEvents = (
552
- events: readonly MessageStreamEvent[],
595
+ events: readonly SessionStreamEvent[],
553
596
  cache: InterruptedTurnEventCache,
554
- ): readonly MessageStreamEvent[] => {
597
+ ): readonly SessionStreamEvent[] => {
555
598
  if (events === cache.lastEvents) return cache.interruptions;
556
599
 
557
600
  const scanned = cache.lastEvents;
@@ -559,40 +602,61 @@ export const collectInterruptedTurnEvents = (
559
602
  scanned.length === 0 ||
560
603
  events[scanned.length - 1] === scanned[scanned.length - 1];
561
604
 
562
- let interruptions = resumesScan ? cache.interruptions : [];
605
+ const interruptions = resumesScan ? cache.interruptions : [];
606
+ let nextInterruptions: SessionStreamEvent[] | undefined;
563
607
  for (let i = resumesScan ? scanned.length : 0; i < events.length; i++) {
564
608
  const event = events[i]!;
565
- if (isTurnInterruption(event)) interruptions = [...interruptions, event];
609
+ if (isTurnInterruption(event)) {
610
+ nextInterruptions ??= [...interruptions];
611
+ nextInterruptions.push(event);
612
+ }
566
613
  }
567
614
 
568
615
  cache.lastEvents = events;
569
- cache.interruptions = interruptions;
570
- return interruptions;
616
+ cache.interruptions = nextInterruptions ?? interruptions;
617
+ return cache.interruptions;
571
618
  };
572
619
 
573
620
  const getInterruptedTurns = (
574
- events: readonly MessageStreamEvent[] | undefined,
621
+ events: readonly SessionStreamEvent[] | undefined,
575
622
  ) => {
576
623
  const interrupted = new Map<string, MessageStatus>();
624
+ const sessionFailures = new Map<string, MessageStatus>();
577
625
  for (const event of events ?? []) {
578
- if (event.type === "turn.failed") {
626
+ if (event.type === "turn.settled" && event.data.outcome === "failed") {
579
627
  interrupted.set(event.data.turnId, {
580
628
  type: "incomplete",
581
629
  reason: "error",
582
- error: { code: event.data.code, message: event.data.message },
630
+ ...(event.data.error && { error: toAssistantError(event.data.error) }),
583
631
  });
584
- } else if (event.type === "turn.cancelled") {
632
+ } else if (
633
+ event.type === "turn.settled" &&
634
+ event.data.outcome === "cancelled"
635
+ ) {
585
636
  interrupted.set(event.data.turnId, ASSISTANT_CANCELLED_STATUS);
637
+ } else if (
638
+ event.type === "session.ended" &&
639
+ event.data.outcome === "failed" &&
640
+ event.data.cause &&
641
+ "turnId" in event.data.cause
642
+ ) {
643
+ sessionFailures.set(event.data.cause.turnId, {
644
+ type: "incomplete",
645
+ reason: "error",
646
+ ...(event.data.error && { error: toAssistantError(event.data.error) }),
647
+ });
586
648
  }
587
649
  }
650
+ for (const [turnId, status] of sessionFailures)
651
+ interrupted.set(turnId, status);
588
652
  return interrupted;
589
653
  };
590
654
 
591
655
  /**
592
- * Converts the full Eve message data object into assistant-ui thread messages.
656
+ * Converts Eve's conversation messages into assistant-ui thread messages.
593
657
  */
594
658
  export const convertEveMessages = (
595
- data: EveMessageData,
659
+ data: Pick<ConversationState, "messages">,
596
660
  options: ConvertEveMessagesOptions = {},
597
661
  ): ThreadMessage[] => {
598
662
  const interruptedTurns = getInterruptedTurns(options.events);
@@ -608,9 +672,9 @@ export const convertEveMessages = (
608
672
  };
609
673
 
610
674
  /**
611
- * Structural subset of the `string | UserContent` message content Eve's `send`
612
- * API accepts. The helpers declare the shapes they produce and leave
613
- * assignability to the send call site, checked against the installed version.
675
+ * Structural subset of the message content Eve's `send` API accepts. The
676
+ * helpers declare the shapes they produce and leave assignability to the send
677
+ * call site, checked against the installed version.
614
678
  */
615
679
  export type EveMessageContent =
616
680
  | string
@@ -678,8 +742,6 @@ export const getEveMessageContent = (
678
742
  }
679
743
 
680
744
  case "audio": {
681
- // A data URL's own media type wins over `mediaType` downstream, so the
682
- // envelope is rebuilt from the typed format rather than forwarded.
683
745
  const mediaType = `audio/${part.audio.format}`;
684
746
  const data = part.audio.data;
685
747
  const source = resolveFilePartSource({ data, mimeType: mediaType });
@@ -717,53 +779,54 @@ export const getEveMessageContent = (
717
779
  };
718
780
 
719
781
  /**
720
- * Finds the pending input request answered by the given approval id, so the
721
- * response mapping can honor the request's display mode.
782
+ * Finds the request of the interaction answered by the given approval id, so
783
+ * the answer mapping can honor the request's display mode.
722
784
  */
723
785
  export const findEveInputRequest = (
724
- data: EveMessageData,
786
+ data: Pick<ConversationState, "messages">,
725
787
  approvalId: string,
726
- ): EveMessageInputRequest | undefined => {
788
+ ): Interaction["request"] | undefined => {
727
789
  for (const message of data.messages) {
728
790
  for (const part of message.parts) {
729
- if (part.type === "dynamic-tool" && part.approval?.id === approvalId) {
730
- return part.toolMetadata?.eve?.inputRequest;
731
- }
791
+ const interaction =
792
+ part.type === "interaction"
793
+ ? part
794
+ : part.type === "tool"
795
+ ? part.interaction
796
+ : undefined;
797
+ if (interaction?.interactionId === approvalId) return interaction.request;
732
798
  }
733
799
  }
734
800
  return undefined;
735
801
  };
736
802
 
737
803
  /**
738
- * Converts an assistant-ui tool approval response into an Eve input response.
804
+ * Converts an assistant-ui tool approval response into an Eve `Answer`.
739
805
  *
740
- * When `inputRequest` is known, every returned response carries either an
741
- * option the request declares or a free-form answer. A literal option match
742
- * wins, then the `"approve"` / `"cancel"` option the response's boolean decision
806
+ * When `inputRequest` is known, every returned answer carries an option the
807
+ * request declares, the literal `"approve"` / `"deny"` for an approval that
808
+ * declares no options, or a free-form answer. A literal option match wins,
809
+ * then the `"approve"` / `"deny"` option the response's boolean decision
743
810
  * names, then the response's `text` (or its `reason`, for a caller that
744
811
  * predates the first-class answer field) when the request takes a free-form
745
812
  * answer (`display: "text"`, `allowFreeform`, or no options at all, and never
746
813
  * `display: "confirmation"`) and the response is not a refusal.
747
- * A one-argument call, or a part with no request, still forwards a caller
748
- * `optionId` as it always has, including ids the request never declared.
749
814
  *
750
815
  * A response the known request cannot record as the answer it asked for throws
751
- * instead of being submitted, because eve resolves a request the moment any
752
- * response for it arrives, and an empty one is recorded as an answer with no
753
- * content. Not sending leaves the request pending, so the caller can retry or
816
+ * instead of being submitted, because Eve resolves an interaction the moment
817
+ * any answer for it arrives, and an empty one is recorded as an answer with no
818
+ * content. Not sending leaves the interaction open, so the caller can retry or
754
819
  * the user can answer it as an ordinary message.
755
820
  *
756
- * With no request to map against — a one-argument call, or a part carrying no
757
- * `toolMetadata.eve.inputRequest` — the response maps as it always has, to the
758
- * literal `"approve"` / `"cancel"` option every eve approval declares. Guessing
759
- * a display mode is what this mapper stopped doing; an unknown request is not
760
- * a guess about one.
821
+ * With no request to map against, a one-argument call forwards a caller
822
+ * `optionId` as is and otherwise maps the decision to the literal `"approve"` /
823
+ * `"deny"` option every Eve approval declares.
761
824
  */
762
825
  export const toEveInputResponse = (
763
826
  response: RespondToToolApprovalOptions,
764
- inputRequest?: EveMessageInputRequest,
765
- ): InputResponse => {
766
- const requestId = response.approvalId;
827
+ inputRequest?: Interaction["request"],
828
+ ): Answer => {
829
+ const interactionId = response.approvalId;
767
830
  const options = inputRequest?.options;
768
831
  const text = response.text ?? response.reason;
769
832
 
@@ -773,13 +836,13 @@ export const toEveInputResponse = (
773
836
  options?.some((option) => option.id === response.optionId)
774
837
  ) {
775
838
  return {
776
- requestId,
839
+ interactionId,
777
840
  optionId: response.optionId,
778
841
  ...(text && { text }),
779
842
  };
780
843
  }
781
844
  throw new Error(
782
- `Eve input request "${requestId}" has no option with id "${response.optionId}"; respond with one of: ${
845
+ `Eve interaction "${interactionId}" has no option with id "${response.optionId}"; respond with one of: ${
783
846
  options?.length
784
847
  ? options.map((option) => option.id).join(", ")
785
848
  : "(the request carries no options)"
@@ -787,29 +850,23 @@ export const toEveInputResponse = (
787
850
  );
788
851
  }
789
852
 
790
- const decisionOptionId = response.approved ? "approve" : "cancel";
791
- // Eve's harness always declares `approve`/`cancel` on a `tool-approval`
792
- // request, so honoring the kind when the options were dropped keeps a
793
- // non-spec approval payload answerable rather than stuck. A tool-approval
794
- // that declares its own options still requires one of them.
853
+ const decisionOptionId = response.approved
854
+ ? APPROVE_OPTION_ID
855
+ : DENY_OPTION_ID;
795
856
  if (
796
857
  !inputRequest ||
797
- (inputRequest.kind === "tool-approval" && !options?.length) ||
858
+ (inputRequest.kind === "approval" && !options?.length) ||
798
859
  options?.some((option) => option.id === decisionOptionId)
799
860
  ) {
800
- return { requestId, optionId: decisionOptionId, ...(text && { text }) };
861
+ return { interactionId, optionId: decisionOptionId, ...(text && { text }) };
801
862
  }
802
-
803
- // Eve recognises an approval by its literal two-option `approve`/`cancel`
804
- // shape, so past this point the request is a question and the boolean
805
- // carries no decision eve would act on.
806
863
  const acceptsText = acceptsFreeformAnswer(inputRequest);
807
864
  if (acceptsText && text && response.approved !== false) {
808
- return { requestId, text };
865
+ return { interactionId, text };
809
866
  }
810
867
 
811
868
  throw new Error(
812
- `Eve input request "${requestId}" (${inputRequest.prompt}) was not answered by this response; ${
869
+ `Eve interaction "${interactionId}" (${inputRequest.prompt}) was not answered by this response; ${
813
870
  options?.length
814
871
  ? `respond with one of: ${options.map((option) => option.id).join(", ")}`
815
872
  : !acceptsText