@assistant-ui/core 0.3.2 → 0.3.3

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 (56) hide show
  1. package/dist/index.d.ts +4 -4
  2. package/dist/index.js.map +1 -1
  3. package/dist/internal.d.ts +6 -6
  4. package/dist/internal.js +5 -5
  5. package/dist/react/primitive-hooks/useActionBarCopy.js +51 -24
  6. package/dist/react/primitive-hooks/useActionBarCopy.js.map +1 -1
  7. package/dist/react/primitives/message/MessageGroupedParts.d.ts +2 -1
  8. package/dist/react/primitives/message/MessageGroupedParts.d.ts.map +1 -1
  9. package/dist/react/primitives/message/MessageGroupedParts.js +2 -2
  10. package/dist/react/primitives/message/MessageGroupedParts.js.map +1 -1
  11. package/dist/react/runtimes/cloud/auiV0.d.ts +24 -2
  12. package/dist/react/runtimes/cloud/auiV0.d.ts.map +1 -1
  13. package/dist/react/runtimes/cloud/auiV0.js +24 -5
  14. package/dist/react/runtimes/cloud/auiV0.js.map +1 -1
  15. package/dist/runtime/api/message-runtime.d.ts.map +1 -1
  16. package/dist/runtime/api/message-runtime.js +5 -1
  17. package/dist/runtime/api/message-runtime.js.map +1 -1
  18. package/dist/runtime/internal.d.ts +4 -4
  19. package/dist/runtime/internal.js +4 -4
  20. package/dist/store/clients/chain-of-thought-client.d.ts.map +1 -1
  21. package/dist/store/clients/chain-of-thought-client.js +29 -23
  22. package/dist/store/clients/chain-of-thought-client.js.map +1 -1
  23. package/dist/store/clients/thread-message-client.d.ts.map +1 -1
  24. package/dist/store/clients/thread-message-client.js +101 -89
  25. package/dist/store/clients/thread-message-client.js.map +1 -1
  26. package/dist/types/index.d.ts +2 -2
  27. package/dist/types/message.d.ts +11 -1
  28. package/dist/types/message.d.ts.map +1 -1
  29. package/dist/types/message.js.map +1 -1
  30. package/dist/utils/getGroupStatus.d.ts +9 -0
  31. package/dist/utils/getGroupStatus.d.ts.map +1 -0
  32. package/dist/utils/getGroupStatus.js +15 -0
  33. package/dist/utils/getGroupStatus.js.map +1 -0
  34. package/dist/utils/normalizePartStatus.d.ts +8 -0
  35. package/dist/utils/normalizePartStatus.d.ts.map +1 -0
  36. package/dist/utils/normalizePartStatus.js +39 -0
  37. package/dist/utils/normalizePartStatus.js.map +1 -0
  38. package/package.json +2 -2
  39. package/src/index.ts +1 -0
  40. package/src/internal.ts +1 -0
  41. package/src/react/primitive-hooks/useActionBarCopy.test.ts +109 -14
  42. package/src/react/primitive-hooks/useActionBarCopy.ts +27 -2
  43. package/src/react/primitives/message/MessageGroupedParts.tsx +4 -4
  44. package/src/react/runtimes/cloud/auiV0.ts +51 -12
  45. package/src/runtime/api/message-runtime.test.ts +140 -0
  46. package/src/runtime/api/message-runtime.ts +10 -5
  47. package/src/store/clients/chain-of-thought-client.ts +2 -7
  48. package/src/store/clients/thread-message-client.test.ts +84 -0
  49. package/src/store/clients/thread-message-client.ts +14 -4
  50. package/src/tests/auiV0Encode.test.ts +93 -0
  51. package/src/types/index.ts +1 -0
  52. package/src/types/message.ts +19 -0
  53. package/src/utils/getGroupStatus.test.ts +36 -0
  54. package/src/utils/getGroupStatus.ts +31 -0
  55. package/src/utils/normalizePartStatus.test.ts +59 -0
  56. package/src/utils/normalizePartStatus.ts +55 -0
@@ -1,13 +1,8 @@
1
1
  import type {
2
- DataMessagePart,
3
- FileMessagePart,
4
- ImageMessagePart,
5
2
  MessageStatus,
6
3
  SourceProviderMetadata,
7
4
  ThreadMessage,
8
- TextMessagePart,
9
5
  ToolApprovalOption,
10
- Unstable_AudioMessagePart,
11
6
  } from "../../../types/message";
12
7
  import type { CompleteAttachment } from "../../../types/attachment";
13
8
  import { fromThreadMessageLike } from "../../../runtime/utils/thread-message-like";
@@ -85,11 +80,33 @@ type AuiV0MessagePart =
85
80
  };
86
81
 
87
82
  type AuiV0AttachmentPart =
88
- | TextMessagePart
89
- | ImageMessagePart
90
- | FileMessagePart
91
- | Unstable_AudioMessagePart
92
- | DataMessagePart<ReadonlyJSONValue>;
83
+ | {
84
+ readonly type: "text";
85
+ readonly text: string;
86
+ }
87
+ | {
88
+ readonly type: "image";
89
+ readonly image: string;
90
+ readonly filename?: string;
91
+ }
92
+ | {
93
+ readonly type: "file";
94
+ readonly data: string;
95
+ readonly mimeType: string;
96
+ readonly filename?: string;
97
+ }
98
+ | {
99
+ readonly type: "audio";
100
+ readonly audio: {
101
+ readonly data: string;
102
+ readonly format: "mp3" | "wav";
103
+ };
104
+ }
105
+ | {
106
+ readonly type: "data";
107
+ readonly name: string;
108
+ readonly data: ReadonlyJSONValue;
109
+ };
93
110
 
94
111
  type AuiV0Attachment = {
95
112
  readonly id: string;
@@ -125,16 +142,38 @@ const encodeAttachmentPart = (
125
142
  const type = part.type;
126
143
  switch (type) {
127
144
  case "text":
145
+ return { type: "text", text: part.text };
146
+
128
147
  case "image":
148
+ return {
149
+ type: "image",
150
+ image: part.image,
151
+ ...(part.filename != null ? { filename: part.filename } : undefined),
152
+ };
153
+
129
154
  case "file":
155
+ return {
156
+ type: "file",
157
+ data: part.data,
158
+ mimeType: part.mimeType,
159
+ ...(part.filename != null ? { filename: part.filename } : undefined),
160
+ };
161
+
130
162
  case "audio":
131
- return part;
163
+ return {
164
+ type: "audio",
165
+ audio: { data: part.audio.data, format: part.audio.format },
166
+ };
132
167
 
133
168
  case "data": {
134
169
  if (!isJSONValue(part.data)) {
135
170
  console.warn(`attachment data is not JSON! ${JSON.stringify(part)}`);
136
171
  }
137
- return { ...part, data: part.data as ReadonlyJSONValue };
172
+ return {
173
+ type: "data",
174
+ name: part.name,
175
+ data: part.data as ReadonlyJSONValue,
176
+ };
138
177
  }
139
178
 
140
179
  default: {
@@ -1,8 +1,10 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import type { CompleteAttachment } from "../../types/attachment";
3
+ import type { ThreadAssistantMessage } from "../../types/message";
3
4
  import type { ThreadRuntimeCoreBinding } from "./thread-runtime";
4
5
  import {
5
6
  MessageRuntimeImpl,
7
+ toMessagePartStatus,
6
8
  type MessageState,
7
9
  type MessageStateBinding,
8
10
  } from "./message-runtime";
@@ -63,6 +65,144 @@ const threadBinding = {
63
65
  subscribe: () => () => {},
64
66
  } as unknown as ThreadRuntimeCoreBinding;
65
67
 
68
+ const createAssistantMessage = (
69
+ content: ThreadAssistantMessage["content"],
70
+ status: ThreadAssistantMessage["status"],
71
+ ): ThreadAssistantMessage => ({
72
+ id: "message-1",
73
+ role: "assistant",
74
+ createdAt: new Date(0),
75
+ content,
76
+ status,
77
+ metadata: {
78
+ unstable_state: null,
79
+ unstable_annotations: [],
80
+ unstable_data: [],
81
+ steps: [],
82
+ custom: {},
83
+ },
84
+ });
85
+
86
+ describe("toMessagePartStatus", () => {
87
+ it("honours a supplied running status on a non-last part", () => {
88
+ const message = createAssistantMessage(
89
+ [
90
+ { type: "text", text: "first", status: { type: "running" } },
91
+ { type: "text", text: "last" },
92
+ ],
93
+ { type: "running" },
94
+ );
95
+
96
+ expect(toMessagePartStatus(message, 0, message.content[0]!)).toEqual({
97
+ type: "running",
98
+ });
99
+ });
100
+
101
+ it("honours a supplied complete status on the last part", () => {
102
+ const message = createAssistantMessage(
103
+ [{ type: "reasoning", text: "done", status: { type: "complete" } }],
104
+ { type: "running" },
105
+ );
106
+
107
+ expect(toMessagePartStatus(message, 0, message.content[0]!)).toEqual({
108
+ type: "complete",
109
+ });
110
+ });
111
+
112
+ it("ignores supplied statuses after the message completes", () => {
113
+ const message = createAssistantMessage(
114
+ [{ type: "text", text: "truncated", status: { type: "running" } }],
115
+ { type: "complete", reason: "stop" },
116
+ );
117
+
118
+ expect(toMessagePartStatus(message, 0, message.content[0]!)).toEqual({
119
+ type: "complete",
120
+ reason: "stop",
121
+ });
122
+ });
123
+
124
+ it("falls back to positional statuses for statusless running parts", () => {
125
+ const message = createAssistantMessage(
126
+ [
127
+ { type: "text", text: "first" },
128
+ { type: "reasoning", text: "last" },
129
+ ],
130
+ { type: "running" },
131
+ );
132
+
133
+ expect(toMessagePartStatus(message, 0, message.content[0]!)).toEqual({
134
+ type: "complete",
135
+ });
136
+ expect(toMessagePartStatus(message, 1, message.content[1]!)).toEqual({
137
+ type: "running",
138
+ });
139
+ });
140
+
141
+ it("preserves tool-call status derivation", () => {
142
+ const unresolved = createAssistantMessage(
143
+ [
144
+ {
145
+ type: "tool-call",
146
+ toolCallId: "call-1",
147
+ toolName: "weather",
148
+ args: {},
149
+ argsText: "{}",
150
+ },
151
+ ],
152
+ { type: "running" },
153
+ );
154
+ const resolved = createAssistantMessage(
155
+ [
156
+ {
157
+ type: "tool-call",
158
+ toolCallId: "call-1",
159
+ toolName: "weather",
160
+ args: {},
161
+ argsText: "{}",
162
+ result: "sunny",
163
+ },
164
+ ],
165
+ { type: "running" },
166
+ );
167
+
168
+ expect(toMessagePartStatus(unresolved, 0, unresolved.content[0]!)).toEqual({
169
+ type: "running",
170
+ });
171
+ expect(toMessagePartStatus(resolved, 0, resolved.content[0]!)).toEqual({
172
+ type: "complete",
173
+ });
174
+ });
175
+
176
+ it("normalizes supplied upstream statuses", () => {
177
+ const upstreamComplete = {
178
+ type: "text",
179
+ text: "done",
180
+ status: { type: "complete", reason: "unknown" },
181
+ } as unknown as ThreadAssistantMessage["content"][number];
182
+ const upstreamIncomplete = {
183
+ type: "reasoning",
184
+ text: "interrupted",
185
+ status: {
186
+ type: "incomplete",
187
+ reason: "unknown",
188
+ error: "upstream error",
189
+ },
190
+ } as unknown as ThreadAssistantMessage["content"][number];
191
+ const message = createAssistantMessage(
192
+ [upstreamComplete, upstreamIncomplete],
193
+ { type: "running" },
194
+ );
195
+
196
+ expect(toMessagePartStatus(message, 0, message.content[0]!)).toEqual({
197
+ type: "complete",
198
+ });
199
+ expect(toMessagePartStatus(message, 1, message.content[1]!)).toEqual({
200
+ type: "incomplete",
201
+ reason: "other",
202
+ });
203
+ });
204
+ });
205
+
66
206
  describe("MessageRuntimeImpl paths", () => {
67
207
  it("appends nested selectors to the message path", () => {
68
208
  const runtime = new MessageRuntimeImpl(messageBinding, threadBinding);
@@ -8,6 +8,10 @@ import type {
8
8
  } from "../../types/message";
9
9
  import type { Unsubscribe } from "../../types/unsubscribe";
10
10
  import type { MessagePartStatus, RunConfig } from "../../types/message";
11
+ import {
12
+ COMPLETE_STATUS,
13
+ normalizePartStatus,
14
+ } from "../../utils/normalizePartStatus";
11
15
  import { getThreadMessageText } from "../../utils/text";
12
16
  import { NestedSubscriptionSubject } from "../../subscribable/subscribable";
13
17
  import {
@@ -32,10 +36,6 @@ import type { MessageRuntimePath } from "./paths";
32
36
  import type { ThreadRuntimeCoreBinding } from "./thread-runtime";
33
37
  import type { MessageStateBinding } from "./bindings";
34
38
 
35
- const COMPLETE_STATUS: MessagePartStatus = Object.freeze({
36
- type: "complete",
37
- });
38
-
39
39
  export const toMessagePartStatus = (
40
40
  message: ThreadMessage,
41
41
  partIndex: number,
@@ -51,6 +51,11 @@ export const toMessagePartStatus = (
51
51
  }
52
52
  }
53
53
 
54
+ if (message.status.type === "running") {
55
+ const status = normalizePartStatus(part);
56
+ if (status) return status;
57
+ }
58
+
54
59
  const isLastPart = partIndex === Math.max(0, message.content.length - 1);
55
60
  if (message.status.type === "requires-action") return COMPLETE_STATUS;
56
61
  return isLastPart ? (message.status as MessagePartStatus) : COMPLETE_STATUS;
@@ -70,7 +75,7 @@ const getMessagePartState = (
70
75
  return Object.freeze({
71
76
  ...part,
72
77
  ...{ [symbolInnerMessage]: (part as any)[symbolInnerMessage] },
73
- status,
78
+ status: status as MessagePartStatus,
74
79
  });
75
80
  };
76
81
 
@@ -5,12 +5,8 @@ import type {
5
5
  ChainOfThoughtState,
6
6
  ChainOfThoughtPart,
7
7
  } from "../scopes/chain-of-thought";
8
- import type { MessagePartStatus } from "../../types/message";
9
8
  import type { PartMethods } from "../scopes/part";
10
-
11
- const COMPLETE_STATUS: MessagePartStatus = Object.freeze({
12
- type: "complete",
13
- });
9
+ import { getGroupStatus } from "../../utils/getGroupStatus";
14
10
 
15
11
  const useChainOfThoughtClient = ({
16
12
  parts,
@@ -22,8 +18,7 @@ const useChainOfThoughtClient = ({
22
18
  const [collapsed, setCollapsed] = useState(true);
23
19
 
24
20
  const status = useMemo(() => {
25
- const lastPart = parts[parts.length - 1];
26
- return lastPart?.status ?? COMPLETE_STATUS;
21
+ return getGroupStatus(parts);
27
22
  }, [parts]);
28
23
 
29
24
  const state = useMemo<ChainOfThoughtState>(
@@ -0,0 +1,84 @@
1
+ import { createTapRoot, useResource } from "@assistant-ui/tap";
2
+ import { describe, expect, it } from "vitest";
3
+ import type { ThreadAssistantMessage } from "../../types/message";
4
+ import { ThreadMessageClient } from "./thread-message-client";
5
+
6
+ describe("ThreadMessageClient", () => {
7
+ const getPartStatus = (
8
+ part: ThreadAssistantMessage["content"][number],
9
+ status: ThreadAssistantMessage["status"],
10
+ ) => {
11
+ const message: ThreadAssistantMessage = {
12
+ id: "message-1",
13
+ role: "assistant",
14
+ createdAt: new Date(0),
15
+ content: [part],
16
+ status,
17
+ metadata: {
18
+ unstable_state: null,
19
+ unstable_annotations: [],
20
+ unstable_data: [],
21
+ steps: [],
22
+ custom: {},
23
+ },
24
+ };
25
+ const root = createTapRoot(function ThreadMessageRoot() {
26
+ return useResource(ThreadMessageClient({ message, index: 0 }));
27
+ });
28
+
29
+ try {
30
+ return root.getValue().getState().parts[0]?.status;
31
+ } finally {
32
+ root.unmount();
33
+ }
34
+ };
35
+
36
+ it("preserves a running part status on a running detached message", () => {
37
+ const part = {
38
+ type: "text",
39
+ text: "done",
40
+ status: { type: "running" },
41
+ } as unknown as ThreadAssistantMessage["content"][number];
42
+
43
+ expect(getPartStatus(part, { type: "running" })).toEqual({
44
+ type: "running",
45
+ });
46
+ });
47
+
48
+ it("normalizes an unknown incomplete reason on a running detached message", () => {
49
+ const part = {
50
+ type: "text",
51
+ text: "done",
52
+ status: { type: "incomplete", reason: "unknown" },
53
+ } as unknown as ThreadAssistantMessage["content"][number];
54
+
55
+ expect(getPartStatus(part, { type: "running" })).toEqual({
56
+ type: "incomplete",
57
+ reason: "other",
58
+ });
59
+ });
60
+
61
+ it("normalizes an upstream complete reason on a running detached message", () => {
62
+ const part = {
63
+ type: "text",
64
+ text: "done",
65
+ status: { type: "complete", reason: "unknown" },
66
+ } as unknown as ThreadAssistantMessage["content"][number];
67
+
68
+ expect(getPartStatus(part, { type: "running" })).toEqual({
69
+ type: "complete",
70
+ });
71
+ });
72
+
73
+ it("marks parts complete on a non-running detached message", () => {
74
+ const part = {
75
+ type: "text",
76
+ text: "done",
77
+ status: { type: "running" },
78
+ } as unknown as ThreadAssistantMessage["content"][number];
79
+
80
+ expect(getPartStatus(part, { type: "complete", reason: "stop" })).toEqual({
81
+ type: "complete",
82
+ });
83
+ });
84
+ });
@@ -11,19 +11,27 @@ import { useClientLookup } from "@assistant-ui/store";
11
11
  import type { MessageState } from "../scopes/message";
12
12
  import type { PartState } from "../scopes/part";
13
13
  import { NoOpComposerClient } from "./no-op-composer-client";
14
+ import {
15
+ COMPLETE_STATUS,
16
+ normalizePartStatus,
17
+ } from "../../utils/normalizePartStatus";
14
18
  import { getThreadMessageText } from "../../utils/text";
15
19
 
16
20
  const useThreadMessagePartClient = ({
17
21
  part,
22
+ isMessageRunning,
18
23
  }: {
19
24
  part: ThreadAssistantMessagePart | ThreadUserMessagePart;
25
+ isMessageRunning: boolean;
20
26
  }): ClientOutput<"part"> => {
21
27
  const state = useMemo<PartState>(() => {
22
28
  return {
23
29
  ...part,
24
- status: { type: "complete" },
30
+ status: isMessageRunning
31
+ ? (normalizePartStatus(part) ?? COMPLETE_STATUS)
32
+ : COMPLETE_STATUS,
25
33
  };
26
- }, [part]);
34
+ }, [part, isMessageRunning]);
27
35
 
28
36
  return {
29
37
  getState: () => state,
@@ -74,6 +82,8 @@ const useThreadMessageClient = ({
74
82
  }: ThreadMessageClientProps): ClientOutput<"message"> => {
75
83
  const [isCopiedState, setIsCopied] = useState(false);
76
84
  const [isHoveringState, setIsHovering] = useState(false);
85
+ const isMessageRunning =
86
+ message.role === "assistant" && message.status.type === "running";
77
87
 
78
88
  const parts = useClientLookup(
79
89
  message.content.map((part, idx) =>
@@ -81,8 +91,8 @@ const useThreadMessageClient = ({
81
91
  "toolCallId" in part && part.toolCallId != null
82
92
  ? `toolCallId-${part.toolCallId}`
83
93
  : `index-${idx}`,
84
- ThreadMessagePartClient({ part }),
85
- [part],
94
+ ThreadMessagePartClient({ part, isMessageRunning }),
95
+ [part, isMessageRunning],
86
96
  ),
87
97
  ),
88
98
  );
@@ -155,6 +155,99 @@ describe("auiV0Encode", () => {
155
155
  },
156
156
  ]);
157
157
  });
158
+
159
+ it("drops per-part status from message parts in the core cloud encoder", () => {
160
+ const encoded = auiV0Encode({
161
+ id: "m1",
162
+ createdAt: new Date("2026-03-15T00:00:00.000Z"),
163
+ role: "assistant",
164
+ status: { type: "complete", reason: "stop" },
165
+ metadata: {
166
+ unstable_state: null,
167
+ unstable_annotations: [],
168
+ unstable_data: [],
169
+ steps: [],
170
+ custom: {},
171
+ },
172
+ content: [
173
+ { type: "reasoning", text: "thinking", status: { type: "complete" } },
174
+ { type: "text", text: "answer", status: { type: "running" } },
175
+ ],
176
+ });
177
+
178
+ expect(encoded.content).toEqual([
179
+ { type: "reasoning", text: "thinking" },
180
+ { type: "text", text: "answer" },
181
+ ]);
182
+ });
183
+
184
+ it("drops per-part status from attachment content in the core cloud encoder", () => {
185
+ const encoded = auiV0Encode({
186
+ id: "m1",
187
+ createdAt: new Date("2026-03-15T00:00:00.000Z"),
188
+ role: "user",
189
+ metadata: { custom: {} },
190
+ content: [{ type: "text", text: "please review this" }],
191
+ attachments: [
192
+ {
193
+ id: "att-1",
194
+ type: "document",
195
+ name: "notes.txt",
196
+ status: { type: "complete" },
197
+ content: [
198
+ { type: "text", text: "notes", status: { type: "running" } },
199
+ ],
200
+ },
201
+ ],
202
+ });
203
+
204
+ expect(encoded.attachments?.[0]?.content).toEqual([
205
+ { type: "text", text: "notes" },
206
+ ]);
207
+ });
208
+
209
+ it("preserves every attachment content field the wire shape carries in the core cloud encoder", () => {
210
+ const encoded = auiV0Encode({
211
+ id: "m1",
212
+ createdAt: new Date("2026-03-15T00:00:00.000Z"),
213
+ role: "user",
214
+ metadata: { custom: {} },
215
+ content: [{ type: "text", text: "please review these" }],
216
+ attachments: [
217
+ {
218
+ id: "att-1",
219
+ type: "file",
220
+ name: "bundle",
221
+ status: { type: "complete" },
222
+ content: [
223
+ {
224
+ type: "image",
225
+ image: "data:image/png;base64,iVBORw0KGgo=",
226
+ filename: "shot.png",
227
+ },
228
+ {
229
+ type: "audio",
230
+ audio: { data: "data:audio/mp3;base64,SUQzAw==", format: "mp3" },
231
+ },
232
+ { type: "data", name: "telemetry", data: { runs: 3 } },
233
+ ],
234
+ },
235
+ ],
236
+ });
237
+
238
+ expect(encoded.attachments?.[0]?.content).toEqual([
239
+ {
240
+ type: "image",
241
+ image: "data:image/png;base64,iVBORw0KGgo=",
242
+ filename: "shot.png",
243
+ },
244
+ {
245
+ type: "audio",
246
+ audio: { data: "data:audio/mp3;base64,SUQzAw==", format: "mp3" },
247
+ },
248
+ { type: "data", name: "telemetry", data: { runs: 3 } },
249
+ ]);
250
+ });
158
251
  });
159
252
 
160
253
  describe("auiV0Decode", () => {
@@ -18,6 +18,7 @@ export type {
18
18
  ThreadAssistantMessagePart,
19
19
  // Message status
20
20
  MessagePartStatus,
21
+ MessagePartStreamStatus,
21
22
  ToolCallMessagePartStatus,
22
23
  MessageStatus,
23
24
  // Thread messages
@@ -14,6 +14,7 @@ export type PartProviderMetadata = {
14
14
  export type TextMessagePart = {
15
15
  readonly type: "text";
16
16
  readonly text: string;
17
+ readonly status?: MessagePartStreamStatus;
17
18
  readonly providerMetadata?: PartProviderMetadata;
18
19
  readonly parentId?: string;
19
20
  };
@@ -21,6 +22,7 @@ export type TextMessagePart = {
21
22
  export type ReasoningMessagePart = {
22
23
  readonly type: "reasoning";
23
24
  readonly text: string;
25
+ readonly status?: MessagePartStreamStatus;
24
26
  readonly providerMetadata?: PartProviderMetadata;
25
27
  readonly parentId?: string;
26
28
  };
@@ -266,6 +268,23 @@ export type MessagePartStatus =
266
268
  readonly error?: unknown;
267
269
  };
268
270
 
271
+ export type MessagePartStreamStatus =
272
+ | {
273
+ readonly type: "running";
274
+ }
275
+ | {
276
+ readonly type: "complete";
277
+ }
278
+ | {
279
+ readonly type: "incomplete";
280
+ readonly reason:
281
+ | "cancelled"
282
+ | "length"
283
+ | "content-filter"
284
+ | "other"
285
+ | "error";
286
+ };
287
+
269
288
  export type ToolCallMessagePartStatus =
270
289
  | {
271
290
  /** The tool call is waiting for UI or human input before continuing. */
@@ -0,0 +1,36 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { getGroupStatus } from "./getGroupStatus";
3
+
4
+ describe("getGroupStatus", () => {
5
+ it("reports running when the first member is running and the last is complete", () => {
6
+ const parts = [
7
+ { status: { type: "running" as const } },
8
+ { status: { type: "complete" as const } },
9
+ ];
10
+ const status = getGroupStatus(parts);
11
+
12
+ expect(status).toEqual({ type: "running" });
13
+ expect(getGroupStatus(parts, [0, 1])).toBe(status);
14
+ expect(status).toBe(getGroupStatus([{ status: { type: "running" } }]));
15
+ expect(Object.isFrozen(status)).toBe(true);
16
+ });
17
+
18
+ it("reports the last status when every member is complete", () => {
19
+ const status = { type: "complete" } as const;
20
+
21
+ expect(getGroupStatus([{ status }, { status }])).toBe(status);
22
+ });
23
+
24
+ it("reports complete for an empty group", () => {
25
+ expect(getGroupStatus([])).toEqual({ type: "complete" });
26
+ });
27
+
28
+ it("matches the positional outcome for statusless adapters", () => {
29
+ expect(
30
+ getGroupStatus(
31
+ [{ status: { type: "complete" } }, { status: { type: "running" } }],
32
+ [0, 1],
33
+ ),
34
+ ).toEqual({ type: "running" });
35
+ });
36
+ });
@@ -0,0 +1,31 @@
1
+ import type {
2
+ MessagePartStatus,
3
+ ToolCallMessagePartStatus,
4
+ } from "../types/message";
5
+ import { COMPLETE_STATUS, RUNNING_STATUS } from "./normalizePartStatus";
6
+
7
+ type PartWithStatus = {
8
+ readonly status: MessagePartStatus | ToolCallMessagePartStatus;
9
+ };
10
+
11
+ export const getGroupStatus = (
12
+ parts: readonly (PartWithStatus | undefined)[],
13
+ indices?: readonly number[],
14
+ ): MessagePartStatus | ToolCallMessagePartStatus => {
15
+ if (indices) {
16
+ for (const index of indices) {
17
+ if (parts[index]?.status.type === "running") return RUNNING_STATUS;
18
+ }
19
+
20
+ const lastIndex = indices.at(-1);
21
+ return lastIndex === undefined
22
+ ? COMPLETE_STATUS
23
+ : (parts[lastIndex]?.status ?? COMPLETE_STATUS);
24
+ }
25
+
26
+ for (const part of parts) {
27
+ if (part?.status.type === "running") return RUNNING_STATUS;
28
+ }
29
+
30
+ return parts.at(-1)?.status ?? COMPLETE_STATUS;
31
+ };