@assistant-ui/core 0.2.5 → 0.2.6

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 (91) hide show
  1. package/dist/index.d.ts +2 -2
  2. package/dist/react/index.d.ts +2 -1
  3. package/dist/react/index.js +2 -1
  4. package/dist/react/primitives/message/MessageGroupedParts.d.ts +25 -21
  5. package/dist/react/primitives/message/MessageGroupedParts.d.ts.map +1 -1
  6. package/dist/react/primitives/message/MessageGroupedParts.js +6 -7
  7. package/dist/react/primitives/message/MessageGroupedParts.js.map +1 -1
  8. package/dist/react/primitives/message/MessageParts.d.ts +2 -1
  9. package/dist/react/primitives/message/MessageParts.d.ts.map +1 -1
  10. package/dist/react/primitives/message/MessageParts.js +9 -4
  11. package/dist/react/primitives/message/MessageParts.js.map +1 -1
  12. package/dist/react/providers/TextMessagePartProvider.d.ts.map +1 -1
  13. package/dist/react/providers/TextMessagePartProvider.js +3 -0
  14. package/dist/react/providers/TextMessagePartProvider.js.map +1 -1
  15. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.d.ts +3 -1
  16. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.d.ts.map +1 -1
  17. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts +3 -1
  18. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts.map +1 -1
  19. package/dist/react/runtimes/external-message-converter.d.ts.map +1 -1
  20. package/dist/react/runtimes/external-message-converter.js +7 -3
  21. package/dist/react/runtimes/external-message-converter.js.map +1 -1
  22. package/dist/react/types/MessagePartComponentTypes.d.ts +8 -0
  23. package/dist/react/types/MessagePartComponentTypes.d.ts.map +1 -1
  24. package/dist/react/utils/groupParts.d.ts +40 -12
  25. package/dist/react/utils/groupParts.d.ts.map +1 -1
  26. package/dist/react/utils/groupParts.js +51 -9
  27. package/dist/react/utils/groupParts.js.map +1 -1
  28. package/dist/runtime/api/message-part-runtime.d.ts +8 -0
  29. package/dist/runtime/api/message-part-runtime.d.ts.map +1 -1
  30. package/dist/runtime/api/message-part-runtime.js +13 -0
  31. package/dist/runtime/api/message-part-runtime.js.map +1 -1
  32. package/dist/runtime/api/thread-runtime.d.ts +2 -1
  33. package/dist/runtime/api/thread-runtime.d.ts.map +1 -1
  34. package/dist/runtime/base/base-thread-runtime-core.d.ts +2 -1
  35. package/dist/runtime/base/base-thread-runtime-core.d.ts.map +1 -1
  36. package/dist/runtime/base/base-thread-runtime-core.js.map +1 -1
  37. package/dist/runtime/interfaces/thread-runtime-core.d.ts +7 -1
  38. package/dist/runtime/interfaces/thread-runtime-core.d.ts.map +1 -1
  39. package/dist/runtime/utils/thread-message-like.d.ts +10 -0
  40. package/dist/runtime/utils/thread-message-like.d.ts.map +1 -1
  41. package/dist/runtime/utils/thread-message-like.js.map +1 -1
  42. package/dist/runtimes/external-store/external-store-adapter.d.ts +2 -1
  43. package/dist/runtimes/external-store/external-store-adapter.d.ts.map +1 -1
  44. package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts +2 -1
  45. package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts.map +1 -1
  46. package/dist/runtimes/external-store/external-store-thread-runtime-core.js +4 -0
  47. package/dist/runtimes/external-store/external-store-thread-runtime-core.js.map +1 -1
  48. package/dist/runtimes/local/local-thread-runtime-core.d.ts +2 -1
  49. package/dist/runtimes/local/local-thread-runtime-core.d.ts.map +1 -1
  50. package/dist/runtimes/local/local-thread-runtime-core.js +3 -0
  51. package/dist/runtimes/local/local-thread-runtime-core.js.map +1 -1
  52. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.d.ts +1 -0
  53. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.d.ts.map +1 -1
  54. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.js +3 -0
  55. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.js.map +1 -1
  56. package/dist/runtimes/remote-thread-list/empty-thread-core.d.ts.map +1 -1
  57. package/dist/runtimes/remote-thread-list/empty-thread-core.js +3 -0
  58. package/dist/runtimes/remote-thread-list/empty-thread-core.js.map +1 -1
  59. package/dist/store/clients/thread-message-client.d.ts.map +1 -1
  60. package/dist/store/clients/thread-message-client.js +3 -0
  61. package/dist/store/clients/thread-message-client.js.map +1 -1
  62. package/dist/store/runtime-clients/message-part-runtime-client.js +1 -0
  63. package/dist/store/runtime-clients/message-part-runtime-client.js.map +1 -1
  64. package/dist/store/scopes/part.d.ts +7 -0
  65. package/dist/store/scopes/part.d.ts.map +1 -1
  66. package/dist/types/message.d.ts +6 -0
  67. package/dist/types/message.d.ts.map +1 -1
  68. package/dist/types/message.js.map +1 -1
  69. package/package.json +2 -2
  70. package/src/index.ts +1 -0
  71. package/src/react/index.ts +1 -0
  72. package/src/react/primitives/message/MessageGroupedParts.tsx +38 -31
  73. package/src/react/primitives/message/MessageParts.tsx +14 -1
  74. package/src/react/providers/TextMessagePartProvider.tsx +3 -0
  75. package/src/react/runtimes/external-message-converter.ts +25 -12
  76. package/src/react/types/MessagePartComponentTypes.ts +8 -0
  77. package/src/react/utils/groupParts.ts +67 -22
  78. package/src/runtime/api/message-part-runtime.ts +26 -0
  79. package/src/runtime/base/base-thread-runtime-core.ts +4 -0
  80. package/src/runtime/interfaces/thread-runtime-core.ts +7 -0
  81. package/src/runtime/utils/thread-message-like.ts +7 -0
  82. package/src/runtimes/external-store/external-store-adapter.ts +4 -0
  83. package/src/runtimes/external-store/external-store-thread-runtime-core.ts +7 -0
  84. package/src/runtimes/local/local-thread-runtime-core.ts +5 -0
  85. package/src/runtimes/readonly/ReadonlyThreadRuntimeCore.ts +4 -0
  86. package/src/runtimes/remote-thread-list/empty-thread-core.ts +4 -0
  87. package/src/store/clients/thread-message-client.ts +3 -0
  88. package/src/store/runtime-clients/message-part-runtime-client.ts +2 -0
  89. package/src/store/scopes/part.ts +4 -0
  90. package/src/tests/groupParts.test.ts +118 -32
  91. package/src/types/message.ts +7 -0
@@ -1,3 +1,6 @@
1
+ import { isMcpAppUri } from "../../types/message";
2
+ import type { PartState } from "../../store/scopes/part";
3
+
1
4
  /**
2
5
  * Hierarchical adjacent-coalescing grouping for message parts.
3
6
  *
@@ -11,16 +14,71 @@
11
14
  */
12
15
 
13
16
  /**
14
- * Public group key type. Group keys must be prefixed with `group-` so
15
- * that a unified `switch (part.type)` in the renderer can distinguish
16
- * a group key (e.g. `"group-thought"`) from a real part type
17
- * (`"text"`, `"tool-call"`).
17
+ * Symbol attached to memoizable `groupBy` functions (e.g. those returned
18
+ * by {@link groupPartByType}). Carries a string fingerprint of the config
19
+ * so `MessagePrimitive.GroupedParts` can memo the tree on
20
+ * `[parts, memoKey]` across renders — even when the helper call site
21
+ * reconstructs the function each render.
22
+ */
23
+ export const GROUPBY_MEMO_KEY: unique symbol = Symbol.for(
24
+ "@assistant-ui/groupBy.memoKey",
25
+ );
26
+
27
+ /**
28
+ * Synthetic part-type key recognized by {@link groupPartByType}: a
29
+ * tool-call whose `mcp.app.resourceUri` points at an assistant-ui MCP
30
+ * app. Map this key to control how MCP-app tool calls are grouped —
31
+ * separately from regular `"tool-call"` parts.
32
+ */
33
+ type GroupPartType = PartState["type"] | "mcp-app";
34
+
35
+ /**
36
+ * Build a `groupBy` from a `part.type → group-key path` lookup.
37
+ * Parts whose type isn't in the map are left ungrouped. The returned
38
+ * function carries a stable {@link GROUPBY_MEMO_KEY} fingerprint so
39
+ * `<MessagePrimitive.GroupedParts>` can memoize its tree across renders.
40
+ *
41
+ * Special key `"mcp-app"` matches tool-call parts that point at an
42
+ * assistant-ui MCP app resource (`ui://...`) and takes precedence over
43
+ * the `"tool-call"` entry for those parts.
44
+ *
45
+ * @example
46
+ * ```tsx
47
+ * <MessagePrimitive.GroupedParts
48
+ * groupBy={groupPartByType({
49
+ * reasoning: ["group-thought", "group-reasoning"],
50
+ * "tool-call": ["group-thought", "group-tool"],
51
+ * "mcp-app": [],
52
+ * })}
53
+ * >
54
+ * {({ part, children }) => { ... }}
55
+ * </MessagePrimitive.GroupedParts>
56
+ * ```
18
57
  */
19
- export type GroupKey<TKey extends `group-${string}` = `group-${string}`> =
20
- | TKey
21
- | readonly TKey[]
22
- | null
23
- | undefined;
58
+ export const groupPartByType = <TKey extends `group-${string}`>(
59
+ map: Partial<Readonly<Record<GroupPartType, readonly TKey[]>>>,
60
+ ): ((part: PartState) => readonly TKey[]) => {
61
+ const lookup = map as Readonly<Record<string, readonly TKey[] | undefined>>;
62
+ const fn = ((part) => {
63
+ if (
64
+ part.type === "tool-call" &&
65
+ lookup["mcp-app"] !== undefined &&
66
+ isMcpAppUri(part.mcp?.app?.resourceUri)
67
+ ) {
68
+ return lookup["mcp-app"]!;
69
+ }
70
+ return lookup[part.type] ?? [];
71
+ }) as ((part: PartState) => readonly TKey[]) & {
72
+ [GROUPBY_MEMO_KEY]?: string;
73
+ };
74
+ // Sort keys so the fingerprint is insensitive to map insertion order —
75
+ // two maps with the same key/value pairs but different declaration order
76
+ // would otherwise hash differently and invalidate the memo unnecessarily.
77
+ const sortedKeys = Object.keys(map).sort();
78
+ const sortedEntries = sortedKeys.map((k) => [k, map[k as keyof typeof map]]);
79
+ fn[GROUPBY_MEMO_KEY] = `groupPartByType:${JSON.stringify(sortedEntries)}`;
80
+ return fn;
81
+ };
24
82
 
25
83
  export type GroupNode = GroupNodeGroup | GroupNodePart;
26
84
 
@@ -43,19 +101,6 @@ export interface GroupNodePart {
43
101
  readonly nodeKey: string;
44
102
  }
45
103
 
46
- const EMPTY_PATH: readonly string[] = Object.freeze([]);
47
-
48
- /**
49
- * Normalize a `groupBy` return value to a path array.
50
- * `null`/`undefined`/`[]` → `[]` (ungrouped).
51
- * `"foo"` → `["foo"]`. Arrays pass through.
52
- */
53
- export const normalizeGroupKey = (key: GroupKey): readonly string[] => {
54
- if (key == null) return EMPTY_PATH;
55
- if (typeof key === "string") return [key];
56
- return key;
57
- };
58
-
59
104
  interface BuildFrame {
60
105
  key: string;
61
106
  nodeKey: string;
@@ -26,6 +26,7 @@ type MessagePartSnapshotBinding = SubscribableWithState<
26
26
  export type MessagePartRuntime = {
27
27
  addToolResult(result: any | ToolResponse<any>): void;
28
28
  resumeToolCall(payload: unknown): void;
29
+ respondToToolApproval(response: { approved: boolean; reason?: string }): void;
29
30
 
30
31
  readonly path: MessagePartRuntimePath;
31
32
  getState(): MessagePartState;
@@ -48,6 +49,7 @@ export class MessagePartRuntimeImpl implements MessagePartRuntime {
48
49
  protected __internal_bindMethods() {
49
50
  this.addToolResult = this.addToolResult.bind(this);
50
51
  this.resumeToolCall = this.resumeToolCall.bind(this);
52
+ this.respondToToolApproval = this.respondToToolApproval.bind(this);
51
53
  this.getState = this.getState.bind(this);
52
54
  this.subscribe = this.subscribe.bind(this);
53
55
  }
@@ -102,6 +104,30 @@ export class MessagePartRuntimeImpl implements MessagePartRuntime {
102
104
  });
103
105
  }
104
106
 
107
+ public respondToToolApproval(response: {
108
+ approved: boolean;
109
+ reason?: string;
110
+ }) {
111
+ const state = this.contentBinding.getState();
112
+ if (!state) throw new Error("Message part is not available");
113
+
114
+ if (state.type !== "tool-call")
115
+ throw new Error(
116
+ "Tried to respond to tool approval on non-tool message part",
117
+ );
118
+
119
+ if (!state.approval || state.approval.approved !== undefined)
120
+ throw new Error("Tool call has no pending approval");
121
+
122
+ if (!this.threadApi) throw new Error("Thread API is not available");
123
+
124
+ this.threadApi.getState().respondToToolApproval({
125
+ approvalId: state.approval.id,
126
+ approved: response.approved,
127
+ ...(response.reason != null && { reason: response.reason }),
128
+ });
129
+ }
130
+
105
131
  public subscribe(callback: () => void) {
106
132
  return this.contentBinding.subscribe(callback);
107
133
  }
@@ -15,6 +15,7 @@ import { DefaultThreadComposerRuntimeCore } from "./default-thread-composer-runt
15
15
  import type {
16
16
  AddToolResultOptions,
17
17
  ResumeToolCallOptions,
18
+ RespondToToolApprovalOptions,
18
19
  ThreadSuggestion,
19
20
  SubmitFeedbackOptions,
20
21
  ThreadRuntimeCore,
@@ -59,6 +60,9 @@ export abstract class BaseThreadRuntimeCore implements ThreadRuntimeCore {
59
60
  public abstract resumeRun(config: ResumeRunConfig): void;
60
61
  public abstract addToolResult(options: AddToolResultOptions): void;
61
62
  public abstract resumeToolCall(options: ResumeToolCallOptions): void;
63
+ public abstract respondToToolApproval(
64
+ options: RespondToToolApprovalOptions,
65
+ ): void;
62
66
  public abstract cancelRun(): void;
63
67
  public abstract exportExternalState(): any;
64
68
  public abstract importExternalState(state: any): void;
@@ -45,6 +45,12 @@ export type ResumeToolCallOptions = {
45
45
  payload: unknown;
46
46
  };
47
47
 
48
+ export type RespondToToolApprovalOptions = {
49
+ approvalId: string;
50
+ approved: boolean;
51
+ reason?: string;
52
+ };
53
+
48
54
  export type SubmitFeedbackOptions = {
49
55
  messageId: string;
50
56
  type: "negative" | "positive";
@@ -137,6 +143,7 @@ export type ThreadRuntimeCore = Readonly<{
137
143
 
138
144
  addToolResult: (options: AddToolResultOptions) => void;
139
145
  resumeToolCall: (options: ResumeToolCallOptions) => void;
146
+ respondToToolApproval: (options: RespondToToolApprovalOptions) => void;
140
147
 
141
148
  speak: (messageId: string) => void;
142
149
  stopSpeaking: () => void;
@@ -54,6 +54,13 @@ export type ThreadMessageLike = {
54
54
  readonly isError?: boolean | undefined;
55
55
  readonly parentId?: string | undefined;
56
56
  readonly messages?: readonly ThreadMessage[] | undefined;
57
+ readonly interrupt?: { type: "human"; payload: unknown };
58
+ readonly approval?: {
59
+ readonly id: string;
60
+ readonly approved?: boolean;
61
+ readonly reason?: string;
62
+ readonly isAutomatic?: boolean;
63
+ };
57
64
  }
58
65
  )[];
59
66
  readonly id?: string | undefined;
@@ -9,6 +9,7 @@ import type { RealtimeVoiceAdapter } from "../../adapters/voice";
9
9
  import type { FeedbackAdapter } from "../../adapters/feedback";
10
10
  import type {
11
11
  AddToolResultOptions,
12
+ RespondToToolApprovalOptions,
12
13
  StartRunConfig,
13
14
  ResumeRunConfig,
14
15
  ThreadSuggestion,
@@ -108,6 +109,9 @@ type ExternalStoreAdapterBase<T> = {
108
109
  onResumeToolCall?:
109
110
  | ((options: { toolCallId: string; payload: unknown }) => void)
110
111
  | undefined;
112
+ onRespondToToolApproval?:
113
+ | ((options: RespondToToolApprovalOptions) => Promise<void> | void)
114
+ | undefined;
111
115
  convertMessage?: ExternalStoreMessageConverter<T> | undefined;
112
116
  adapters?:
113
117
  | {
@@ -3,6 +3,7 @@ import type {
3
3
  AddToolResultOptions,
4
4
  ResumeRunConfig,
5
5
  ResumeToolCallOptions,
6
+ RespondToToolApprovalOptions,
6
7
  StartRunConfig,
7
8
  ThreadSuggestion,
8
9
  } from "../../runtime/interfaces/thread-runtime-core";
@@ -372,6 +373,12 @@ export class ExternalStoreThreadRuntimeCore
372
373
  this._store.onResumeToolCall(options);
373
374
  }
374
375
 
376
+ public respondToToolApproval(options: RespondToToolApprovalOptions) {
377
+ if (!this._store.onRespondToToolApproval)
378
+ throw new Error("Runtime does not support tool approvals.");
379
+ this._store.onRespondToToolApproval(options);
380
+ }
381
+
375
382
  public override reset(initialMessages?: readonly ThreadMessageLike[]) {
376
383
  this._lastSyncedMessageIds = new Set();
377
384
  const repo = new MessageRepository();
@@ -9,6 +9,7 @@ import type { LocalRuntimeOptionsBase } from "./local-runtime-options";
9
9
  import type {
10
10
  AddToolResultOptions,
11
11
  ResumeToolCallOptions,
12
+ RespondToToolApprovalOptions,
12
13
  ThreadSuggestion,
13
14
  ThreadRuntimeCore,
14
15
  StartRunConfig,
@@ -535,4 +536,8 @@ export class LocalThreadRuntimeCore
535
536
  public resumeToolCall(_options: ResumeToolCallOptions) {
536
537
  throw new Error("Local runtime does not support resuming tool calls.");
537
538
  }
539
+
540
+ public respondToToolApproval(_options: RespondToToolApprovalOptions) {
541
+ throw new Error("Local runtime does not support tool approvals.");
542
+ }
538
543
  }
@@ -65,6 +65,10 @@ export class ReadonlyThreadRuntimeCore
65
65
  throw READONLY_THREAD_ERROR;
66
66
  }
67
67
 
68
+ respondToToolApproval(): void {
69
+ throw READONLY_THREAD_ERROR;
70
+ }
71
+
68
72
  speak(): void {
69
73
  throw READONLY_THREAD_ERROR;
70
74
  }
@@ -40,6 +40,10 @@ export const EMPTY_THREAD_CORE: ThreadRuntimeCore = {
40
40
  throw EMPTY_THREAD_ERROR;
41
41
  },
42
42
 
43
+ respondToToolApproval() {
44
+ throw EMPTY_THREAD_ERROR;
45
+ },
46
+
43
47
  speak() {
44
48
  throw EMPTY_THREAD_ERROR;
45
49
  },
@@ -38,6 +38,9 @@ const ThreadMessagePartClient = resource(
38
38
  resumeToolCall: () => {
39
39
  throw new Error("Not supported");
40
40
  },
41
+ respondToToolApproval: () => {
42
+ throw new Error("Not supported");
43
+ },
41
44
  };
42
45
  },
43
46
  );
@@ -11,6 +11,8 @@ export const MessagePartClient = resource(
11
11
  getState: () => state,
12
12
  addToolResult: (result) => runtime.addToolResult(result),
13
13
  resumeToolCall: (payload) => runtime.resumeToolCall(payload),
14
+ respondToToolApproval: (response) =>
15
+ runtime.respondToToolApproval(response),
14
16
  __internal_getRuntime: () => runtime,
15
17
  };
16
18
  },
@@ -26,6 +26,10 @@ export type PartMethods = {
26
26
  * This is useful when a tool has requested human input and is waiting for a response.
27
27
  */
28
28
  resumeToolCall(payload: unknown): void;
29
+ /**
30
+ * Respond to a server-side tool approval gate. The approval id is read from the part.
31
+ */
32
+ respondToToolApproval(response: { approved: boolean; reason?: string }): void;
29
33
  __internal_getRuntime?(): MessagePartRuntime;
30
34
  };
31
35
 
@@ -1,12 +1,13 @@
1
1
  import { describe, expect, it } from "vitest";
2
+ import type { PartState } from "../store/scopes/part";
2
3
  import {
3
4
  buildGroupTree,
4
- normalizeGroupKey,
5
+ GROUPBY_MEMO_KEY,
6
+ groupPartByType,
5
7
  type GroupNode,
6
8
  } from "../react/utils/groupParts";
7
9
 
8
- const asPaths = (keys: readonly (string | readonly string[] | null)[]) =>
9
- keys.map((k) => normalizeGroupKey(k));
10
+ const asPaths = (keys: readonly (readonly string[])[]) => keys;
10
11
 
11
12
  // Compact tree dump: "G:key#nodeKey[i,j]{...}" | "P:#nodeKey(i)"
12
13
  const dump = (nodes: readonly GroupNode[]): string =>
@@ -20,39 +21,23 @@ const dump = (nodes: readonly GroupNode[]): string =>
20
21
  })
21
22
  .join(",");
22
23
 
23
- describe("normalizeGroupKey", () => {
24
- it("maps null/undefined/[] to []", () => {
25
- expect(normalizeGroupKey(null)).toEqual([]);
26
- expect(normalizeGroupKey(undefined)).toEqual([]);
27
- expect(normalizeGroupKey([])).toEqual([]);
28
- });
29
-
30
- it("wraps a string into a single-element array", () => {
31
- expect(normalizeGroupKey("foo")).toEqual(["foo"]);
32
- });
33
-
34
- it("passes arrays through", () => {
35
- expect(normalizeGroupKey(["a", "b"])).toEqual(["a", "b"]);
36
- });
37
- });
38
-
39
24
  describe("buildGroupTree", () => {
40
25
  it("returns an empty list for no parts", () => {
41
26
  expect(buildGroupTree([])).toEqual([]);
42
27
  });
43
28
 
44
29
  it("emits one part leaf per ungrouped part (no coalescing)", () => {
45
- const tree = buildGroupTree(asPaths([null, null, null]));
30
+ const tree = buildGroupTree(asPaths([[], [], []]));
46
31
  expect(dump(tree)).toBe("P:#0(0),P:#1(1),P:#2(2)");
47
32
  });
48
33
 
49
34
  it("wraps adjacent same-key parts in one group with one part child each", () => {
50
- const tree = buildGroupTree(asPaths(["a", "a", "a"]));
35
+ const tree = buildGroupTree(asPaths([["a"], ["a"], ["a"]]));
51
36
  expect(dump(tree)).toBe("G:a#0[0,1,2]{P:#0.0(0),P:#0.1(1),P:#0.2(2)}");
52
37
  });
53
38
 
54
39
  it("splits non-adjacent runs of the same key into separate groups", () => {
55
- const tree = buildGroupTree(asPaths(["a", null, "a"]));
40
+ const tree = buildGroupTree(asPaths([["a"], [], ["a"]]));
56
41
  expect(dump(tree)).toBe("G:a#0[0]{P:#0.0(0)},P:#1(1),G:a#2[2]{P:#2.0(2)}");
57
42
  });
58
43
 
@@ -95,20 +80,121 @@ describe("buildGroupTree", () => {
95
80
  );
96
81
  });
97
82
 
98
- it("accepts strings and arrays interchangeably via normalizeGroupKey", () => {
99
- const tree = buildGroupTree([
100
- normalizeGroupKey("A"),
101
- normalizeGroupKey(["A"]),
102
- ]);
103
- expect(dump(tree)).toBe("G:A#0[0,1]{P:#0.0(0),P:#0.1(1)}");
104
- });
105
-
106
83
  it("assigns stable nodeKeys under append (existing keys do not shift)", () => {
107
- const before = buildGroupTree(asPaths([["A"], null]));
108
- const after = buildGroupTree(asPaths([["A"], null, ["B"]]));
84
+ const before = buildGroupTree(asPaths([["A"], []]));
85
+ const after = buildGroupTree(asPaths([["A"], [], ["B"]]));
109
86
 
110
87
  expect(before[0]!.nodeKey).toBe(after[0]!.nodeKey);
111
88
  expect(before[1]!.nodeKey).toBe(after[1]!.nodeKey);
112
89
  expect(after[2]!.nodeKey).toBe("2");
113
90
  });
114
91
  });
92
+
93
+ const part = (overrides: Partial<PartState>): PartState =>
94
+ ({
95
+ type: "text",
96
+ text: "",
97
+ status: { type: "complete" },
98
+ ...overrides,
99
+ }) as PartState;
100
+
101
+ describe("groupPartByType", () => {
102
+ it("maps part.type to the configured path", () => {
103
+ const fn = groupPartByType({
104
+ reasoning: ["group-thought", "group-reasoning"],
105
+ "tool-call": ["group-thought", "group-tool"],
106
+ });
107
+ expect(fn(part({ type: "reasoning" }))).toEqual([
108
+ "group-thought",
109
+ "group-reasoning",
110
+ ]);
111
+ expect(fn(part({ type: "tool-call" }))).toEqual([
112
+ "group-thought",
113
+ "group-tool",
114
+ ]);
115
+ });
116
+
117
+ it("returns [] for part types not in the map", () => {
118
+ const fn = groupPartByType({ reasoning: ["group-r"] });
119
+ expect(fn(part({ type: "text" }))).toEqual([]);
120
+ });
121
+
122
+ it("routes MCP-app tool calls through the 'mcp-app' entry when present", () => {
123
+ const fn = groupPartByType({
124
+ "tool-call": ["group-tool"],
125
+ "mcp-app": [],
126
+ });
127
+ const mcpApp = part({
128
+ type: "tool-call",
129
+ toolName: "render",
130
+ mcp: { app: { resourceUri: "ui://my-app" } },
131
+ } as Partial<PartState>);
132
+ const regular = part({
133
+ type: "tool-call",
134
+ toolName: "search",
135
+ } as Partial<PartState>);
136
+ expect(fn(mcpApp)).toEqual([]);
137
+ expect(fn(regular)).toEqual(["group-tool"]);
138
+ });
139
+
140
+ it("falls back to 'tool-call' for MCP-app parts when 'mcp-app' is absent", () => {
141
+ const fn = groupPartByType({ "tool-call": ["group-tool"] });
142
+ const mcpApp = part({
143
+ type: "tool-call",
144
+ toolName: "render",
145
+ mcp: { app: { resourceUri: "ui://x" } },
146
+ } as Partial<PartState>);
147
+ expect(fn(mcpApp)).toEqual(["group-tool"]);
148
+ });
149
+
150
+ it("does not route non-`ui://` tool calls through 'mcp-app'", () => {
151
+ const fn = groupPartByType({
152
+ "tool-call": ["group-tool"],
153
+ "mcp-app": ["group-mcp"],
154
+ });
155
+ const notMcp = part({
156
+ type: "tool-call",
157
+ toolName: "x",
158
+ mcp: { app: { resourceUri: "http://example.com" } },
159
+ } as Partial<PartState>);
160
+ expect(fn(notMcp)).toEqual(["group-tool"]);
161
+ });
162
+
163
+ it("tags the function with a GROUPBY_MEMO_KEY fingerprint", () => {
164
+ const fn = groupPartByType({ reasoning: ["group-r"] });
165
+ const memoKey = (fn as unknown as { [GROUPBY_MEMO_KEY]: string })[
166
+ GROUPBY_MEMO_KEY
167
+ ];
168
+ expect(memoKey).toMatch(/^groupPartByType:/);
169
+ });
170
+
171
+ it("produces the same fingerprint regardless of map key order", () => {
172
+ const a = groupPartByType({
173
+ reasoning: ["group-r"],
174
+ "tool-call": ["group-t"],
175
+ });
176
+ const b = groupPartByType({
177
+ "tool-call": ["group-t"],
178
+ reasoning: ["group-r"],
179
+ });
180
+ const keyA = (a as unknown as { [GROUPBY_MEMO_KEY]: string })[
181
+ GROUPBY_MEMO_KEY
182
+ ];
183
+ const keyB = (b as unknown as { [GROUPBY_MEMO_KEY]: string })[
184
+ GROUPBY_MEMO_KEY
185
+ ];
186
+ expect(keyA).toBe(keyB);
187
+ });
188
+
189
+ it("produces different fingerprints for different configs", () => {
190
+ const a = groupPartByType({ reasoning: ["group-r"] });
191
+ const b = groupPartByType({ reasoning: ["group-r2"] });
192
+ const keyA = (a as unknown as { [GROUPBY_MEMO_KEY]: string })[
193
+ GROUPBY_MEMO_KEY
194
+ ];
195
+ const keyB = (b as unknown as { [GROUPBY_MEMO_KEY]: string })[
196
+ GROUPBY_MEMO_KEY
197
+ ];
198
+ expect(keyA).not.toBe(keyB);
199
+ });
200
+ });
@@ -165,6 +165,13 @@ export type ToolCallMessagePart<
165
165
  readonly modelContent?: readonly ToolModelContentPart[] | undefined;
166
166
  /** Human-input request that must be resolved before the run can continue. */
167
167
  readonly interrupt?: { type: "human"; payload: unknown };
168
+ /** Server-side approval gate. `approved === undefined` is the only state in which `respondToApproval` may be called. */
169
+ readonly approval?: {
170
+ readonly id: string;
171
+ readonly approved?: boolean;
172
+ readonly reason?: string;
173
+ readonly isAutomatic?: boolean;
174
+ };
168
175
  /** Parent message-part ID when this part belongs to a nested structure. */
169
176
  readonly parentId?: string;
170
177
  /**