@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.
- package/dist/index.d.ts +2 -2
- package/dist/react/index.d.ts +2 -1
- package/dist/react/index.js +2 -1
- package/dist/react/primitives/message/MessageGroupedParts.d.ts +25 -21
- package/dist/react/primitives/message/MessageGroupedParts.d.ts.map +1 -1
- package/dist/react/primitives/message/MessageGroupedParts.js +6 -7
- package/dist/react/primitives/message/MessageGroupedParts.js.map +1 -1
- package/dist/react/primitives/message/MessageParts.d.ts +2 -1
- package/dist/react/primitives/message/MessageParts.d.ts.map +1 -1
- package/dist/react/primitives/message/MessageParts.js +9 -4
- package/dist/react/primitives/message/MessageParts.js.map +1 -1
- package/dist/react/providers/TextMessagePartProvider.d.ts.map +1 -1
- package/dist/react/providers/TextMessagePartProvider.js +3 -0
- package/dist/react/providers/TextMessagePartProvider.js.map +1 -1
- package/dist/react/runtimes/RemoteThreadListHookInstanceManager.d.ts +3 -1
- package/dist/react/runtimes/RemoteThreadListHookInstanceManager.d.ts.map +1 -1
- package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts +3 -1
- package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts.map +1 -1
- package/dist/react/runtimes/external-message-converter.d.ts.map +1 -1
- package/dist/react/runtimes/external-message-converter.js +7 -3
- package/dist/react/runtimes/external-message-converter.js.map +1 -1
- package/dist/react/types/MessagePartComponentTypes.d.ts +8 -0
- package/dist/react/types/MessagePartComponentTypes.d.ts.map +1 -1
- package/dist/react/utils/groupParts.d.ts +40 -12
- package/dist/react/utils/groupParts.d.ts.map +1 -1
- package/dist/react/utils/groupParts.js +51 -9
- package/dist/react/utils/groupParts.js.map +1 -1
- package/dist/runtime/api/message-part-runtime.d.ts +8 -0
- package/dist/runtime/api/message-part-runtime.d.ts.map +1 -1
- package/dist/runtime/api/message-part-runtime.js +13 -0
- package/dist/runtime/api/message-part-runtime.js.map +1 -1
- package/dist/runtime/api/thread-runtime.d.ts +2 -1
- package/dist/runtime/api/thread-runtime.d.ts.map +1 -1
- package/dist/runtime/base/base-thread-runtime-core.d.ts +2 -1
- package/dist/runtime/base/base-thread-runtime-core.d.ts.map +1 -1
- package/dist/runtime/base/base-thread-runtime-core.js.map +1 -1
- package/dist/runtime/interfaces/thread-runtime-core.d.ts +7 -1
- package/dist/runtime/interfaces/thread-runtime-core.d.ts.map +1 -1
- package/dist/runtime/utils/thread-message-like.d.ts +10 -0
- package/dist/runtime/utils/thread-message-like.d.ts.map +1 -1
- package/dist/runtime/utils/thread-message-like.js.map +1 -1
- package/dist/runtimes/external-store/external-store-adapter.d.ts +2 -1
- package/dist/runtimes/external-store/external-store-adapter.d.ts.map +1 -1
- package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts +2 -1
- package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts.map +1 -1
- package/dist/runtimes/external-store/external-store-thread-runtime-core.js +4 -0
- package/dist/runtimes/external-store/external-store-thread-runtime-core.js.map +1 -1
- package/dist/runtimes/local/local-thread-runtime-core.d.ts +2 -1
- package/dist/runtimes/local/local-thread-runtime-core.d.ts.map +1 -1
- package/dist/runtimes/local/local-thread-runtime-core.js +3 -0
- package/dist/runtimes/local/local-thread-runtime-core.js.map +1 -1
- package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.d.ts +1 -0
- package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.d.ts.map +1 -1
- package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.js +3 -0
- package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.js.map +1 -1
- package/dist/runtimes/remote-thread-list/empty-thread-core.d.ts.map +1 -1
- package/dist/runtimes/remote-thread-list/empty-thread-core.js +3 -0
- package/dist/runtimes/remote-thread-list/empty-thread-core.js.map +1 -1
- package/dist/store/clients/thread-message-client.d.ts.map +1 -1
- package/dist/store/clients/thread-message-client.js +3 -0
- package/dist/store/clients/thread-message-client.js.map +1 -1
- package/dist/store/runtime-clients/message-part-runtime-client.js +1 -0
- package/dist/store/runtime-clients/message-part-runtime-client.js.map +1 -1
- package/dist/store/scopes/part.d.ts +7 -0
- package/dist/store/scopes/part.d.ts.map +1 -1
- package/dist/types/message.d.ts +6 -0
- package/dist/types/message.d.ts.map +1 -1
- package/dist/types/message.js.map +1 -1
- package/package.json +2 -2
- package/src/index.ts +1 -0
- package/src/react/index.ts +1 -0
- package/src/react/primitives/message/MessageGroupedParts.tsx +38 -31
- package/src/react/primitives/message/MessageParts.tsx +14 -1
- package/src/react/providers/TextMessagePartProvider.tsx +3 -0
- package/src/react/runtimes/external-message-converter.ts +25 -12
- package/src/react/types/MessagePartComponentTypes.ts +8 -0
- package/src/react/utils/groupParts.ts +67 -22
- package/src/runtime/api/message-part-runtime.ts +26 -0
- package/src/runtime/base/base-thread-runtime-core.ts +4 -0
- package/src/runtime/interfaces/thread-runtime-core.ts +7 -0
- package/src/runtime/utils/thread-message-like.ts +7 -0
- package/src/runtimes/external-store/external-store-adapter.ts +4 -0
- package/src/runtimes/external-store/external-store-thread-runtime-core.ts +7 -0
- package/src/runtimes/local/local-thread-runtime-core.ts +5 -0
- package/src/runtimes/readonly/ReadonlyThreadRuntimeCore.ts +4 -0
- package/src/runtimes/remote-thread-list/empty-thread-core.ts +4 -0
- package/src/store/clients/thread-message-client.ts +3 -0
- package/src/store/runtime-clients/message-part-runtime-client.ts +2 -0
- package/src/store/scopes/part.ts +4 -0
- package/src/tests/groupParts.test.ts +118 -32
- 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
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
|
23
|
-
|
|
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
|
}
|
|
@@ -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
|
},
|
package/src/store/scopes/part.ts
CHANGED
|
@@ -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
|
-
|
|
5
|
+
GROUPBY_MEMO_KEY,
|
|
6
|
+
groupPartByType,
|
|
5
7
|
type GroupNode,
|
|
6
8
|
} from "../react/utils/groupParts";
|
|
7
9
|
|
|
8
|
-
const asPaths = (keys: readonly (
|
|
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([
|
|
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",
|
|
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"],
|
|
108
|
-
const after = buildGroupTree(asPaths([["A"],
|
|
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
|
+
});
|
package/src/types/message.ts
CHANGED
|
@@ -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
|
/**
|