@assistant-ui/eve 0.0.24 → 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 +146 -117
  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 +925 -729
  25. package/src/convertEveMessages.ts +282 -227
  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;
413
-
414
- case "dynamic-tool":
415
- return convertDynamicToolPart(part);
459
+ case "tool":
460
+ return convertToolPart(part);
416
461
 
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);
@@ -538,22 +583,18 @@ const convertEveMessageWithInterruptions = (
538
583
  };
539
584
 
540
585
  export type InterruptedTurnEventCache = {
541
- lastEvents: readonly MessageStreamEvent[];
542
- interruptions: readonly MessageStreamEvent[];
586
+ lastEvents: readonly SessionStreamEvent[];
587
+ interruptions: readonly SessionStreamEvent[];
543
588
  };
544
589
 
545
- const isTurnInterruption = (event: MessageStreamEvent) =>
546
- 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");
547
593
 
548
- /**
549
- * Collects the `turn.failed` and `turn.cancelled` events of an append-only
550
- * event log, scanning only the events appended since the cached call. The
551
- * returned array keeps its identity until a new interruption arrives.
552
- */
553
594
  export const collectInterruptedTurnEvents = (
554
- events: readonly MessageStreamEvent[],
595
+ events: readonly SessionStreamEvent[],
555
596
  cache: InterruptedTurnEventCache,
556
- ): readonly MessageStreamEvent[] => {
597
+ ): readonly SessionStreamEvent[] => {
557
598
  if (events === cache.lastEvents) return cache.interruptions;
558
599
 
559
600
  const scanned = cache.lastEvents;
@@ -561,40 +602,61 @@ export const collectInterruptedTurnEvents = (
561
602
  scanned.length === 0 ||
562
603
  events[scanned.length - 1] === scanned[scanned.length - 1];
563
604
 
564
- let interruptions = resumesScan ? cache.interruptions : [];
605
+ const interruptions = resumesScan ? cache.interruptions : [];
606
+ let nextInterruptions: SessionStreamEvent[] | undefined;
565
607
  for (let i = resumesScan ? scanned.length : 0; i < events.length; i++) {
566
608
  const event = events[i]!;
567
- if (isTurnInterruption(event)) interruptions = [...interruptions, event];
609
+ if (isTurnInterruption(event)) {
610
+ nextInterruptions ??= [...interruptions];
611
+ nextInterruptions.push(event);
612
+ }
568
613
  }
569
614
 
570
615
  cache.lastEvents = events;
571
- cache.interruptions = interruptions;
572
- return interruptions;
616
+ cache.interruptions = nextInterruptions ?? interruptions;
617
+ return cache.interruptions;
573
618
  };
574
619
 
575
620
  const getInterruptedTurns = (
576
- events: readonly MessageStreamEvent[] | undefined,
621
+ events: readonly SessionStreamEvent[] | undefined,
577
622
  ) => {
578
623
  const interrupted = new Map<string, MessageStatus>();
624
+ const sessionFailures = new Map<string, MessageStatus>();
579
625
  for (const event of events ?? []) {
580
- if (event.type === "turn.failed") {
626
+ if (event.type === "turn.settled" && event.data.outcome === "failed") {
581
627
  interrupted.set(event.data.turnId, {
582
628
  type: "incomplete",
583
629
  reason: "error",
584
- error: { code: event.data.code, message: event.data.message },
630
+ ...(event.data.error && { error: toAssistantError(event.data.error) }),
585
631
  });
586
- } else if (event.type === "turn.cancelled") {
632
+ } else if (
633
+ event.type === "turn.settled" &&
634
+ event.data.outcome === "cancelled"
635
+ ) {
587
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
+ });
588
648
  }
589
649
  }
650
+ for (const [turnId, status] of sessionFailures)
651
+ interrupted.set(turnId, status);
590
652
  return interrupted;
591
653
  };
592
654
 
593
655
  /**
594
- * Converts the full Eve message data object into assistant-ui thread messages.
656
+ * Converts Eve's conversation messages into assistant-ui thread messages.
595
657
  */
596
658
  export const convertEveMessages = (
597
- data: EveMessageData,
659
+ data: Pick<ConversationState, "messages">,
598
660
  options: ConvertEveMessagesOptions = {},
599
661
  ): ThreadMessage[] => {
600
662
  const interruptedTurns = getInterruptedTurns(options.events);
@@ -610,9 +672,9 @@ export const convertEveMessages = (
610
672
  };
611
673
 
612
674
  /**
613
- * Structural subset of the `string | UserContent` message content Eve's `send`
614
- * API accepts. The helpers declare the shapes they produce and leave
615
- * 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.
616
678
  */
617
679
  export type EveMessageContent =
618
680
  | string
@@ -680,8 +742,6 @@ export const getEveMessageContent = (
680
742
  }
681
743
 
682
744
  case "audio": {
683
- // A data URL's own media type wins over `mediaType` downstream, so the
684
- // envelope is rebuilt from the typed format rather than forwarded.
685
745
  const mediaType = `audio/${part.audio.format}`;
686
746
  const data = part.audio.data;
687
747
  const source = resolveFilePartSource({ data, mimeType: mediaType });
@@ -719,53 +779,54 @@ export const getEveMessageContent = (
719
779
  };
720
780
 
721
781
  /**
722
- * Finds the pending input request answered by the given approval id, so the
723
- * 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.
724
784
  */
725
785
  export const findEveInputRequest = (
726
- data: EveMessageData,
786
+ data: Pick<ConversationState, "messages">,
727
787
  approvalId: string,
728
- ): EveMessageInputRequest | undefined => {
788
+ ): Interaction["request"] | undefined => {
729
789
  for (const message of data.messages) {
730
790
  for (const part of message.parts) {
731
- if (part.type === "dynamic-tool" && part.approval?.id === approvalId) {
732
- return part.toolMetadata?.eve?.inputRequest;
733
- }
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;
734
798
  }
735
799
  }
736
800
  return undefined;
737
801
  };
738
802
 
739
803
  /**
740
- * Converts an assistant-ui tool approval response into an Eve input response.
804
+ * Converts an assistant-ui tool approval response into an Eve `Answer`.
741
805
  *
742
- * When `inputRequest` is known, every returned response carries either an
743
- * option the request declares or a free-form answer. A literal option match
744
- * 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
745
810
  * names, then the response's `text` (or its `reason`, for a caller that
746
811
  * predates the first-class answer field) when the request takes a free-form
747
812
  * answer (`display: "text"`, `allowFreeform`, or no options at all, and never
748
813
  * `display: "confirmation"`) and the response is not a refusal.
749
- * A one-argument call, or a part with no request, still forwards a caller
750
- * `optionId` as it always has, including ids the request never declared.
751
814
  *
752
815
  * A response the known request cannot record as the answer it asked for throws
753
- * instead of being submitted, because eve resolves a request the moment any
754
- * response for it arrives, and an empty one is recorded as an answer with no
755
- * 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
756
819
  * the user can answer it as an ordinary message.
757
820
  *
758
- * With no request to map against — a one-argument call, or a part carrying no
759
- * `toolMetadata.eve.inputRequest` — the response maps as it always has, to the
760
- * literal `"approve"` / `"cancel"` option every eve approval declares. Guessing
761
- * a display mode is what this mapper stopped doing; an unknown request is not
762
- * 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.
763
824
  */
764
825
  export const toEveInputResponse = (
765
826
  response: RespondToToolApprovalOptions,
766
- inputRequest?: EveMessageInputRequest,
767
- ): InputResponse => {
768
- const requestId = response.approvalId;
827
+ inputRequest?: Interaction["request"],
828
+ ): Answer => {
829
+ const interactionId = response.approvalId;
769
830
  const options = inputRequest?.options;
770
831
  const text = response.text ?? response.reason;
771
832
 
@@ -775,13 +836,13 @@ export const toEveInputResponse = (
775
836
  options?.some((option) => option.id === response.optionId)
776
837
  ) {
777
838
  return {
778
- requestId,
839
+ interactionId,
779
840
  optionId: response.optionId,
780
841
  ...(text && { text }),
781
842
  };
782
843
  }
783
844
  throw new Error(
784
- `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: ${
785
846
  options?.length
786
847
  ? options.map((option) => option.id).join(", ")
787
848
  : "(the request carries no options)"
@@ -789,29 +850,23 @@ export const toEveInputResponse = (
789
850
  );
790
851
  }
791
852
 
792
- const decisionOptionId = response.approved ? "approve" : "cancel";
793
- // Eve's harness always declares `approve`/`cancel` on a `tool-approval`
794
- // request, so honoring the kind when the options were dropped keeps a
795
- // non-spec approval payload answerable rather than stuck. A tool-approval
796
- // that declares its own options still requires one of them.
853
+ const decisionOptionId = response.approved
854
+ ? APPROVE_OPTION_ID
855
+ : DENY_OPTION_ID;
797
856
  if (
798
857
  !inputRequest ||
799
- (inputRequest.kind === "tool-approval" && !options?.length) ||
858
+ (inputRequest.kind === "approval" && !options?.length) ||
800
859
  options?.some((option) => option.id === decisionOptionId)
801
860
  ) {
802
- return { requestId, optionId: decisionOptionId, ...(text && { text }) };
861
+ return { interactionId, optionId: decisionOptionId, ...(text && { text }) };
803
862
  }
804
-
805
- // Eve recognises an approval by its literal two-option `approve`/`cancel`
806
- // shape, so past this point the request is a question and the boolean
807
- // carries no decision eve would act on.
808
863
  const acceptsText = acceptsFreeformAnswer(inputRequest);
809
864
  if (acceptsText && text && response.approved !== false) {
810
- return { requestId, text };
865
+ return { interactionId, text };
811
866
  }
812
867
 
813
868
  throw new Error(
814
- `Eve input request "${requestId}" (${inputRequest.prompt}) was not answered by this response; ${
869
+ `Eve interaction "${interactionId}" (${inputRequest.prompt}) was not answered by this response; ${
815
870
  options?.length
816
871
  ? `respond with one of: ${options.map((option) => option.id).join(", ")}`
817
872
  : !acceptsText