assistant-cloud 0.2.0 → 0.2.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.
Files changed (80) hide show
  1. package/README.md +26 -0
  2. package/dist/AssistantCloud.d.ts +2 -1
  3. package/dist/AssistantCloud.d.ts.map +1 -1
  4. package/dist/AssistantCloud.js +2 -0
  5. package/dist/AssistantCloud.js.map +1 -1
  6. package/dist/AssistantCloudAPI.d.ts +11 -2
  7. package/dist/AssistantCloudAPI.d.ts.map +1 -1
  8. package/dist/AssistantCloudAPI.js +17 -1
  9. package/dist/AssistantCloudAPI.js.map +1 -1
  10. package/dist/AssistantCloudAuthStrategy.d.ts.map +1 -1
  11. package/dist/AssistantCloudAuthStrategy.js +4 -2
  12. package/dist/AssistantCloudAuthStrategy.js.map +1 -1
  13. package/dist/AssistantCloudEvents.d.ts.map +1 -1
  14. package/dist/AssistantCloudEvents.js +4 -0
  15. package/dist/AssistantCloudEvents.js.map +1 -1
  16. package/dist/AssistantCloudRuns.d.ts +1 -0
  17. package/dist/AssistantCloudRuns.d.ts.map +1 -1
  18. package/dist/AssistantCloudRuns.js +2 -1
  19. package/dist/AssistantCloudRuns.js.map +1 -1
  20. package/dist/CloudEngagementReporter.d.ts +74 -0
  21. package/dist/CloudEngagementReporter.d.ts.map +1 -0
  22. package/dist/CloudEngagementReporter.js +132 -0
  23. package/dist/CloudEngagementReporter.js.map +1 -0
  24. package/dist/CloudMessagePersistence.d.ts +8 -9
  25. package/dist/CloudMessagePersistence.d.ts.map +1 -1
  26. package/dist/CloudMessagePersistence.js +18 -11
  27. package/dist/CloudMessagePersistence.js.map +1 -1
  28. package/dist/CloudRunReporter.d.ts +20 -0
  29. package/dist/CloudRunReporter.d.ts.map +1 -0
  30. package/dist/CloudRunReporter.js +38 -0
  31. package/dist/CloudRunReporter.js.map +1 -0
  32. package/dist/FormattedCloudPersistence.d.ts +3 -8
  33. package/dist/FormattedCloudPersistence.d.ts.map +1 -1
  34. package/dist/FormattedCloudPersistence.js +3 -8
  35. package/dist/FormattedCloudPersistence.js.map +1 -1
  36. package/dist/ai-sdk/index.d.ts +33 -0
  37. package/dist/ai-sdk/index.d.ts.map +1 -0
  38. package/dist/ai-sdk/index.js +147 -0
  39. package/dist/ai-sdk/index.js.map +1 -0
  40. package/dist/generateThreadTitle.js +1 -0
  41. package/dist/generateThreadTitle.js.map +1 -1
  42. package/dist/index.d.ts +5 -3
  43. package/dist/index.js +4 -2
  44. package/dist/instrumentMcpSampling.d.ts.map +1 -1
  45. package/dist/instrumentMcpSampling.js +12 -2
  46. package/dist/instrumentMcpSampling.js.map +1 -1
  47. package/dist/runTelemetry.d.ts +17 -1
  48. package/dist/runTelemetry.d.ts.map +1 -1
  49. package/dist/runTelemetry.js.map +1 -1
  50. package/dist/version.d.ts +5 -0
  51. package/dist/version.d.ts.map +1 -0
  52. package/dist/version.js +6 -0
  53. package/dist/version.js.map +1 -0
  54. package/package.json +19 -6
  55. package/src/AssistantCloud.ts +3 -0
  56. package/src/AssistantCloudAPI.ts +31 -1
  57. package/src/AssistantCloudAuthStrategy.ts +4 -2
  58. package/src/AssistantCloudEvents.test.ts +27 -2
  59. package/src/AssistantCloudEvents.ts +5 -0
  60. package/src/AssistantCloudRuns.ts +1 -0
  61. package/src/CloudEngagementReporter.ts +231 -0
  62. package/src/CloudMessagePersistence.ts +24 -13
  63. package/src/CloudRunReporter.ts +38 -0
  64. package/src/FormattedCloudPersistence.ts +3 -8
  65. package/src/ai-sdk/index.test.ts +258 -0
  66. package/src/ai-sdk/index.ts +222 -0
  67. package/src/generateThreadTitle.test.ts +32 -0
  68. package/src/generateThreadTitle.ts +1 -0
  69. package/src/index.ts +11 -1
  70. package/src/instrumentMcpSampling.test.ts +102 -0
  71. package/src/instrumentMcpSampling.ts +16 -2
  72. package/src/runTelemetry.ts +17 -0
  73. package/src/tests/AssistantCloud.test.ts +33 -1
  74. package/src/tests/AssistantCloudAPI.test.ts +51 -0
  75. package/src/tests/AssistantCloudAuthStrategy.test.ts +22 -0
  76. package/src/tests/AssistantCloudProjects.test.ts +1 -5
  77. package/src/tests/CloudEngagementReporter.test.ts +184 -0
  78. package/src/tests/CloudMessagePersistence.test.ts +235 -0
  79. package/src/tests/CloudRunReporter.test.ts +100 -0
  80. package/src/version.ts +4 -0
@@ -0,0 +1,231 @@
1
+ import type { AssistantCloud } from "./AssistantCloud";
2
+ import type {
3
+ AssistantCloudEvent,
4
+ AssistantCloudEventKind,
5
+ } from "./AssistantCloudEvents";
6
+
7
+ export type EngagementEventIds = Pick<
8
+ AssistantCloudEvent,
9
+ "thread_id" | "message_id" | "run_id"
10
+ >;
11
+
12
+ /**
13
+ * Turns the ids an integration knows (its own thread and message ids) into the
14
+ * ids the cloud stores. A send is the one event that may create the remote
15
+ * thread, so it asks for `awaitThread`; every other event reads the ids that
16
+ * already exist.
17
+ */
18
+ export type EngagementIdResolver = (
19
+ threadId: string,
20
+ messageId: string | undefined,
21
+ options: { awaitThread: boolean },
22
+ ) => EngagementEventIds | Promise<EngagementEventIds>;
23
+
24
+ type EngagementEventInit = Pick<AssistantCloudEvent, "value" | "props">;
25
+
26
+ const MAX_REMEMBERED_THREADS = 256;
27
+
28
+ function remember<T>(map: Map<string, T>, threadId: string, value: T): void {
29
+ map.delete(threadId);
30
+ map.set(threadId, value);
31
+ if (map.size > MAX_REMEMBERED_THREADS) {
32
+ map.delete(map.keys().next().value!);
33
+ }
34
+ }
35
+
36
+ function mark(set: Set<string>, threadId: string): void {
37
+ set.delete(threadId);
38
+ set.add(threadId);
39
+ if (set.size > MAX_REMEMBERED_THREADS) {
40
+ set.delete(set.values().next().value!);
41
+ }
42
+ }
43
+
44
+ const passThroughIds: EngagementIdResolver = (threadId, messageId) => ({
45
+ thread_id: threadId,
46
+ ...(messageId !== undefined ? { message_id: messageId } : undefined),
47
+ });
48
+
49
+ /**
50
+ * Derives engagement events from what a chat integration observes and keeps
51
+ * the per thread state the events need: a run's start for the stop duration,
52
+ * a run's end for the time to the next message, one error and one suggestion
53
+ * list per run or thread. A started run is kept until it ends or stops; the
54
+ * rest is kept for the 256 most recently touched threads, so a long session
55
+ * does not grow it without bound. Delivery goes through the cloud's event
56
+ * buffer, so a disabled telemetry setting drops everything here as well.
57
+ */
58
+ export class CloudEngagementReporter {
59
+ private readonly runStartedAt = new Map<string, number>();
60
+ private readonly runEndedAt = new Map<string, number>();
61
+ private readonly shownErrors = new Set<string>();
62
+ private readonly shownSuggestions = new Set<string>();
63
+
64
+ private readonly getCloud: () => AssistantCloud;
65
+ private readonly resolveIds: EngagementIdResolver;
66
+
67
+ constructor(
68
+ cloud: AssistantCloud | (() => AssistantCloud),
69
+ resolveIds: EngagementIdResolver = passThroughIds,
70
+ ) {
71
+ this.getCloud = typeof cloud === "function" ? cloud : () => cloud;
72
+ this.resolveIds = resolveIds;
73
+ }
74
+
75
+ public runStarted(threadId: string): void {
76
+ this.runStartedAt.set(threadId, Date.now());
77
+ this.shownErrors.delete(threadId);
78
+ }
79
+
80
+ public runEnded(threadId: string): void {
81
+ this.runStartedAt.delete(threadId);
82
+ remember(this.runEndedAt, threadId, Date.now());
83
+ }
84
+
85
+ /** Reported once per started run, with the time the run had been going. */
86
+ public runStopped(threadId: string): void {
87
+ const startedAt = this.runStartedAt.get(threadId);
88
+ if (startedAt === undefined) return;
89
+ this.runStartedAt.delete(threadId);
90
+ this.track("run_stopped", threadId, undefined, {
91
+ value: Math.max(0, Date.now() - startedAt),
92
+ });
93
+ }
94
+
95
+ /** Carries the time since the previous run of the thread ended, when known. */
96
+ public messageSent(
97
+ threadId: string,
98
+ init: {
99
+ messageId?: string | undefined;
100
+ chars: number;
101
+ attachments: number;
102
+ },
103
+ ): void {
104
+ const previousRunEndedAt = this.runEndedAt.get(threadId);
105
+ this.track(
106
+ "message_sent",
107
+ threadId,
108
+ init.messageId,
109
+ {
110
+ props: { chars: init.chars, attachments: init.attachments },
111
+ ...(previousRunEndedAt !== undefined
112
+ ? { value: Math.max(0, Date.now() - previousRunEndedAt) }
113
+ : undefined),
114
+ },
115
+ { awaitThread: true },
116
+ );
117
+ }
118
+
119
+ public messageEdited(
120
+ threadId: string,
121
+ init: { messageId: string; chars: number },
122
+ ): void {
123
+ this.track("message_edited", threadId, init.messageId, {
124
+ props: { chars: init.chars },
125
+ });
126
+ }
127
+
128
+ public messageRegenerated(threadId: string, messageId?: string): void {
129
+ this.track("message_regenerated", threadId, messageId);
130
+ }
131
+
132
+ /** Reported once per run, so a retried render of the same error stays one event. */
133
+ public errorShown(
134
+ threadId: string,
135
+ init: { messageId?: string | undefined; reason: string },
136
+ ): void {
137
+ if (this.shownErrors.has(threadId)) return;
138
+ mark(this.shownErrors, threadId);
139
+ this.track("error_shown", threadId, init.messageId, {
140
+ props: { reason: init.reason },
141
+ });
142
+ }
143
+
144
+ /** Reported once per thread, with the number of suggestions on offer. */
145
+ public suggestionsShown(threadId: string, count: number): void {
146
+ if (this.shownSuggestions.has(threadId)) return;
147
+ mark(this.shownSuggestions, threadId);
148
+ this.track("suggestions_shown", threadId, undefined, { value: count });
149
+ }
150
+
151
+ public suggestionClicked(threadId: string): void {
152
+ this.track("suggestion_clicked", threadId);
153
+ }
154
+
155
+ public attachmentAdded(
156
+ threadId: string,
157
+ init: { messageId?: string | undefined; contentType?: string | undefined },
158
+ ): void {
159
+ this.track("attachment_added", threadId, init.messageId, {
160
+ ...(init.contentType ? { props: { type: init.contentType } } : undefined),
161
+ });
162
+ }
163
+
164
+ public attachmentFailed(
165
+ threadId: string,
166
+ init: { messageId?: string | undefined; contentType?: string | undefined },
167
+ ): void {
168
+ this.track("attachment_failed", threadId, init.messageId, {
169
+ ...(init.contentType ? { props: { type: init.contentType } } : undefined),
170
+ });
171
+ }
172
+
173
+ public voiceStarted(threadId: string): void {
174
+ this.track("voice_started", threadId);
175
+ }
176
+
177
+ public speechStarted(threadId: string, messageId?: string): void {
178
+ this.track("speech_started", threadId, messageId);
179
+ }
180
+
181
+ public branchSwitched(threadId: string, messageId?: string): void {
182
+ this.track("branch_switched", threadId, messageId);
183
+ }
184
+
185
+ public messageCopied(threadId: string, messageId?: string): void {
186
+ this.track("message_copied", threadId, messageId);
187
+ }
188
+
189
+ public toolApproved(
190
+ threadId: string,
191
+ messageId: string,
192
+ toolCallId: string,
193
+ toolName: string,
194
+ ): void {
195
+ this.track("tool_approved", threadId, messageId, {
196
+ props: { toolCallId, toolName },
197
+ });
198
+ }
199
+
200
+ public toolRejected(
201
+ threadId: string,
202
+ messageId: string,
203
+ toolCallId: string,
204
+ toolName: string,
205
+ ): void {
206
+ this.track("tool_rejected", threadId, messageId, {
207
+ props: { toolCallId, toolName },
208
+ });
209
+ }
210
+
211
+ /** Reported only for a thread the cloud already knows. */
212
+ public threadSwitched(threadId: string): void {
213
+ this.track("thread_switched", threadId);
214
+ }
215
+
216
+ private track(
217
+ kind: AssistantCloudEventKind,
218
+ threadId: string,
219
+ messageId?: string,
220
+ init: EngagementEventInit = {},
221
+ options: { awaitThread: boolean } = { awaitThread: false },
222
+ ): void {
223
+ void Promise.resolve()
224
+ .then(() => this.resolveIds(threadId, messageId, options))
225
+ .then((ids) => {
226
+ if (kind === "thread_switched" && !ids.thread_id) return;
227
+ this.getCloud().events.track({ kind, ...init, ...ids });
228
+ })
229
+ .catch(() => {});
230
+ }
231
+ }
@@ -5,15 +5,9 @@ import type { CloudMessage } from "./AssistantCloudThreadMessages";
5
5
  const CLOUD_MESSAGE_PAGE_SIZE = 200;
6
6
 
7
7
  /**
8
- * Shared persistence logic for cloud message storage.
9
- *
10
- * Handles ID mapping (local → remote) and parent_id chaining for both:
11
- * - AssistantCloudThreadHistoryAdapter (assistant-ui runtime)
12
- * - useCloudChat (standalone AI SDK hook)
13
- *
14
- * The promise-based ID resolution handles concurrent appends — if message B's
15
- * parent is message A, and A is still being created, we await A's promise
16
- * to get its remote ID before creating B.
8
+ * Appends, updates and loads cloud messages while mapping local ids to cloud
9
+ * ids and chaining parent_id. A parent that is still being created is awaited,
10
+ * so concurrent appends land under the right parent.
17
11
  */
18
12
  export class CloudMessagePersistence {
19
13
  private idMapping = new Map<string, string | Promise<string>>();
@@ -126,11 +120,14 @@ export class CloudMessagePersistence {
126
120
  * The ID mapping is populated so that `isPersisted()` returns true for
127
121
  * loaded messages, preventing re-persistence of already-stored messages.
128
122
  *
123
+ * A loaded ID that an append already maps keeps the remote ID from that append, and falls back to the loaded ID if the append fails.
124
+ *
129
125
  * @param threadId - Remote thread ID
130
126
  * @param format - Optional format filter
131
127
  * @returns Array of cloud messages
132
128
  */
133
129
  async load(threadId: string, format?: string) {
130
+ const idMapping = this.idMapping;
134
131
  const cloud = this.getCloud();
135
132
  const messages: CloudMessage[] = [];
136
133
  const seen = new Set<string>();
@@ -156,17 +153,31 @@ export class CloudMessagePersistence {
156
153
  after = last.id;
157
154
  }
158
155
 
159
- // Populate ID mapping so isPersisted() recognizes loaded messages
160
- for (const m of messages) {
161
- this.idMapping.set(m.id, m.id);
156
+ if (this.idMapping === idMapping) {
157
+ for (const m of messages) {
158
+ const entry = idMapping.get(m.id);
159
+ if (entry === undefined) {
160
+ idMapping.set(m.id, m.id);
161
+ } else if (entry instanceof Promise) {
162
+ void entry.catch(() => {
163
+ const current = idMapping.get(m.id);
164
+ if (current === undefined || current === entry) {
165
+ idMapping.set(m.id, m.id);
166
+ }
167
+ });
168
+ }
169
+ }
162
170
  }
163
171
  return messages;
164
172
  }
165
173
 
166
174
  /**
167
175
  * Reset the ID mapping (call when switching threads).
176
+ *
177
+ * Pending `load()` and `append()` calls are not cancelled and still settle
178
+ * normally, but their results no longer populate the ID mapping.
168
179
  */
169
180
  reset() {
170
- this.idMapping.clear();
181
+ this.idMapping = new Map();
171
182
  }
172
183
  }
@@ -0,0 +1,38 @@
1
+ import type { AssistantCloud } from "./AssistantCloud";
2
+ import { createRunReport, type RunReportInit } from "./runTelemetry";
3
+
4
+ export type CloudRunReportInit = Omit<RunReportInit, "telemetry">;
5
+
6
+ /**
7
+ * Sends run reports the way every client integration has to: nothing while
8
+ * telemetry is off, the cloud's environment, release and tags on every report,
9
+ * the `beforeReport` hook applied last, and a failed send that never surfaces.
10
+ * A report given a key is sent once per key, so an integration that observes
11
+ * the same finished run twice reports it once.
12
+ */
13
+ export class CloudRunReporter {
14
+ private readonly reported = new Set<string>();
15
+ private readonly getCloud: () => AssistantCloud;
16
+
17
+ constructor(cloud: AssistantCloud | (() => AssistantCloud)) {
18
+ this.getCloud = typeof cloud === "function" ? cloud : () => cloud;
19
+ }
20
+
21
+ public async report(init: CloudRunReportInit, key?: string): Promise<void> {
22
+ try {
23
+ const cloud = this.getCloud();
24
+ if (!cloud.telemetry.enabled) return;
25
+ if (key !== undefined && this.reported.has(key)) return;
26
+
27
+ const initial = createRunReport({ ...init, telemetry: cloud.telemetry });
28
+ const { beforeReport } = cloud.telemetry;
29
+ const report = beforeReport ? beforeReport(initial) : initial;
30
+ if (!report) return;
31
+
32
+ if (key !== undefined) this.reported.add(key);
33
+ await cloud.runs.report(report);
34
+ } catch {
35
+ return;
36
+ }
37
+ }
38
+ }
@@ -18,14 +18,9 @@ export type MessageFormatAdapter<TMessage, TStorageFormat> = {
18
18
  };
19
19
 
20
20
  /**
21
- * Wraps a CloudMessagePersistence instance with format-aware encode/decode.
22
- *
23
- * This centralizes the pattern used by both:
24
- * - useCloudChat (standalone AI SDK hook)
25
- * - AssistantCloudThreadHistoryAdapter.withFormat() (assistant-ui runtime)
26
- *
27
- * The persistence parameter is typed structurally (not by class) so callers
28
- * don't need to import CloudMessagePersistence directly.
21
+ * Wraps a CloudMessagePersistence with a MessageFormatAdapter's encode and
22
+ * decode. The persistence parameter is typed structurally, so a caller does
23
+ * not need to import the class.
29
24
  */
30
25
  export const createFormattedPersistence = <TMessage, TStorageFormat>(
31
26
  persistence: {
@@ -0,0 +1,258 @@
1
+ import type { UIMessage } from "ai";
2
+ import { describe, expect, it } from "vitest";
3
+ import {
4
+ aiSDKV6FormatAdapter,
5
+ extractAISDKRunTelemetry,
6
+ type AISDKMessageLike,
7
+ } from "./index";
8
+
9
+ const assistant = (
10
+ parts: UIMessage["parts"],
11
+ metadata?: Record<string, unknown>,
12
+ id = "a1",
13
+ ): UIMessage =>
14
+ ({
15
+ id,
16
+ role: "assistant",
17
+ parts,
18
+ ...(metadata ? { metadata } : {}),
19
+ }) as UIMessage;
20
+
21
+ describe("aiSDKV6FormatAdapter", () => {
22
+ it("stores a message without its id and restores it from the row", () => {
23
+ const message = assistant([{ type: "text", text: "hi" }], { a: 1 });
24
+ const content = aiSDKV6FormatAdapter.encode({ parentId: null, message });
25
+ expect(content).toEqual({
26
+ role: "assistant",
27
+ parts: [{ type: "text", text: "hi" }],
28
+ metadata: { a: 1 },
29
+ });
30
+ expect(
31
+ aiSDKV6FormatAdapter.decode({
32
+ id: "a1",
33
+ parent_id: "u1",
34
+ format: "ai-sdk/v6",
35
+ content,
36
+ }),
37
+ ).toEqual({ parentId: "u1", message });
38
+ expect(aiSDKV6FormatAdapter.getId(message)).toBe("a1");
39
+ });
40
+ });
41
+
42
+ describe("extractAISDKRunTelemetry", () => {
43
+ it("returns null without an assistant message", () => {
44
+ expect(
45
+ extractAISDKRunTelemetry([
46
+ { role: "user", parts: [{ type: "text", text: "q" }] },
47
+ ]),
48
+ ).toBeNull();
49
+ });
50
+
51
+ it("reads tool calls, steps and usage from one streamed message", () => {
52
+ const message = assistant(
53
+ [
54
+ { type: "step-start" },
55
+ {
56
+ type: "tool-weather",
57
+ toolCallId: "call_1",
58
+ state: "output-available",
59
+ input: { city: "Oslo" },
60
+ output: { temp: 3 },
61
+ } as UIMessage["parts"][number],
62
+ { type: "step-start" },
63
+ {
64
+ type: "dynamic-tool",
65
+ toolName: "search",
66
+ toolCallId: "call_2",
67
+ state: "output-available",
68
+ input: { q: "x" },
69
+ output: [{ type: "text", text: "found" }],
70
+ } as UIMessage["parts"][number],
71
+ { type: "step-start" },
72
+ { type: "text", text: "Cold." },
73
+ ],
74
+ {
75
+ usage: { inputTokens: 10, outputTokens: 5 },
76
+ steps: [
77
+ { usage: { promptTokens: 4, completionTokens: 1 } },
78
+ { usage: { inputTokens: 6, outputTokens: 4 } },
79
+ {},
80
+ ],
81
+ samplingCalls: { call_2: [{ duration_ms: 20 }] },
82
+ modelId: "gpt-5.6-luna",
83
+ },
84
+ );
85
+
86
+ expect(extractAISDKRunTelemetry([message])).toEqual({
87
+ assistantMessageId: "a1",
88
+ status: "completed",
89
+ toolCalls: [
90
+ {
91
+ tool_name: "weather",
92
+ tool_call_id: "call_1",
93
+ tool_args: '{"city":"Oslo"}',
94
+ tool_result: '{"temp":3}',
95
+ tool_source: "frontend",
96
+ },
97
+ {
98
+ tool_name: "search",
99
+ tool_call_id: "call_2",
100
+ tool_args: '{"q":"x"}',
101
+ tool_result: '[{"type":"text","text":"found"}]',
102
+ tool_source: "mcp",
103
+ sampling_calls: [{ duration_ms: 20 }],
104
+ },
105
+ ],
106
+ steps: [
107
+ {
108
+ usage: { promptTokens: 4, completionTokens: 1 },
109
+ toolCalls: [expect.objectContaining({ tool_call_id: "call_1" })],
110
+ finishReason: "tool-calls",
111
+ },
112
+ {
113
+ usage: { inputTokens: 6, outputTokens: 4 },
114
+ toolCalls: [expect.objectContaining({ tool_call_id: "call_2" })],
115
+ finishReason: "tool-calls",
116
+ },
117
+ {},
118
+ ],
119
+ totalSteps: 3,
120
+ outputText: "Cold.",
121
+ usage: { inputTokens: 10, outputTokens: 5 },
122
+ modelId: "gpt-5.6-luna",
123
+ metadata: message.metadata,
124
+ });
125
+ });
126
+
127
+ it("sums usage over stored rows without ids and reads legacy tool fields", () => {
128
+ const rows: AISDKMessageLike[] = [
129
+ {
130
+ role: "assistant",
131
+ parts: [
132
+ { type: "step-start" },
133
+ {
134
+ type: "tool-lookup",
135
+ toolCallId: "call_1",
136
+ state: "output-available",
137
+ args: { id: 1 },
138
+ result: "ok",
139
+ } as unknown as UIMessage["parts"][number],
140
+ ],
141
+ metadata: { steps: [{ usage: { inputTokens: 2, outputTokens: 1 } }] },
142
+ },
143
+ {
144
+ role: "assistant",
145
+ parts: [{ type: "step-start" }, { type: "text", text: "done" }],
146
+ metadata: {
147
+ steps: [{ usage: { inputTokens: 3, reasoningTokens: 7 } }],
148
+ },
149
+ },
150
+ ];
151
+ const result = extractAISDKRunTelemetry(rows);
152
+ expect(result?.assistantMessageId).toBeUndefined();
153
+ expect(result?.status).toBe("completed");
154
+ expect(result?.totalSteps).toBe(2);
155
+ expect(result?.usage).toEqual({
156
+ inputTokens: 5,
157
+ outputTokens: 1,
158
+ reasoningTokens: 7,
159
+ });
160
+ expect(result?.toolCalls).toEqual([
161
+ {
162
+ tool_name: "lookup",
163
+ tool_call_id: "call_1",
164
+ tool_args: '{"id":1}',
165
+ tool_result: '"ok"',
166
+ tool_source: "frontend",
167
+ },
168
+ ]);
169
+ });
170
+
171
+ it("attaches sampling calls from the row that made the tool call", () => {
172
+ const rows: AISDKMessageLike[] = [
173
+ {
174
+ role: "assistant",
175
+ parts: [
176
+ {
177
+ type: "dynamic-tool",
178
+ toolName: "search",
179
+ toolCallId: "call_1",
180
+ state: "output-available",
181
+ input: {},
182
+ output: "x",
183
+ } as UIMessage["parts"][number],
184
+ ],
185
+ metadata: { samplingCalls: { call_1: [{ duration_ms: 5 }] } },
186
+ },
187
+ {
188
+ role: "assistant",
189
+ parts: [{ type: "text", text: "done" }],
190
+ metadata: { samplingCalls: {} },
191
+ },
192
+ ];
193
+ expect(extractAISDKRunTelemetry(rows)?.toolCalls).toEqual([
194
+ expect.objectContaining({
195
+ tool_call_id: "call_1",
196
+ tool_source: "mcp",
197
+ sampling_calls: [{ duration_ms: 5 }],
198
+ }),
199
+ ]);
200
+ });
201
+
202
+ it("reads a tool only run as completed with its step closed by the calls", () => {
203
+ const call = (toolCallId: string, state: string, output?: unknown) =>
204
+ ({
205
+ type: "tool-search",
206
+ toolCallId,
207
+ state,
208
+ input: {},
209
+ ...(output !== undefined ? { output } : {}),
210
+ }) as UIMessage["parts"][number];
211
+
212
+ const answered = extractAISDKRunTelemetry([
213
+ assistant([{ type: "step-start" }, call("a", "output-available", [])]),
214
+ ]);
215
+ expect(answered?.status).toBe("completed");
216
+ expect(answered?.steps?.[0]?.finishReason).toBe("tool-calls");
217
+ expect(answered?.outputText).toBeUndefined();
218
+
219
+ const pending = extractAISDKRunTelemetry([
220
+ assistant([{ type: "step-start" }, call("b", "input-available")]),
221
+ ]);
222
+ expect(pending?.status).toBe("completed");
223
+ expect(pending?.steps?.[0]?.finishReason).toBe("tool-calls");
224
+ });
225
+
226
+ it("reads incomplete for a run that produced nothing", () => {
227
+ const empty = extractAISDKRunTelemetry([
228
+ assistant([{ type: "step-start" }]),
229
+ ]);
230
+ expect(empty?.status).toBe("incomplete");
231
+ expect(empty?.totalSteps).toBe(1);
232
+ expect(empty?.steps?.[0]?.finishReason).toBeUndefined();
233
+ expect(empty?.outputText).toBeUndefined();
234
+ });
235
+
236
+ it("reads no steps without step markers", () => {
237
+ const result = extractAISDKRunTelemetry([
238
+ assistant([{ type: "text", text: "hi" }]),
239
+ ]);
240
+ expect(result?.steps).toBeUndefined();
241
+ expect(result?.totalSteps).toBeUndefined();
242
+ });
243
+
244
+ it("skips a stored part without a type", () => {
245
+ const result = extractAISDKRunTelemetry([
246
+ {
247
+ role: "assistant",
248
+ parts: [
249
+ null,
250
+ { toolCallId: "a" },
251
+ { type: "text", text: "hi" },
252
+ ] as never,
253
+ },
254
+ ]);
255
+ expect(result?.outputText).toBe("hi");
256
+ expect(result?.toolCalls).toBeUndefined();
257
+ });
258
+ });