@runtypelabs/persona 4.23.1 → 4.23.2
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/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/{types-9Vxm-PPz.d.cts → types-CI7OckWI.d.cts} +23 -2
- package/dist/animations/{types-9Vxm-PPz.d.ts → types-CI7OckWI.d.ts} +23 -2
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/animations-extra.d.cts +23 -2
- package/dist/animations-extra.d.ts +23 -2
- package/dist/approval-ui.d.cts +46 -3
- package/dist/approval-ui.d.ts +46 -3
- package/dist/artifacts-ui.d.cts +46 -3
- package/dist/artifacts-ui.d.ts +46 -3
- package/dist/codegen.cjs +1 -1
- package/dist/codegen.js +1 -1
- package/dist/context-mentions.d.cts +46 -3
- package/dist/context-mentions.d.ts +46 -3
- package/dist/event-stream-view.d.cts +46 -3
- package/dist/event-stream-view.d.ts +46 -3
- package/dist/forms-ui.d.cts +46 -3
- package/dist/forms-ui.d.ts +46 -3
- package/dist/history-view.d.cts +23 -2
- package/dist/history-view.d.ts +23 -2
- package/dist/index.cjs +41 -41
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +67 -12
- package/dist/index.d.ts +67 -12
- package/dist/index.global.js +26 -26
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +41 -41
- package/dist/index.js.map +1 -1
- package/dist/session-reconnect.d.cts +46 -3
- package/dist/session-reconnect.d.ts +46 -3
- package/dist/smart-dom-reader.d.cts +46 -3
- package/dist/smart-dom-reader.d.ts +46 -3
- package/dist/theme-editor-preview.cjs +48 -48
- package/dist/theme-editor-preview.d.cts +46 -3
- package/dist/theme-editor-preview.d.ts +46 -3
- package/dist/theme-editor-preview.js +31 -31
- package/dist/theme-editor.d.cts +46 -3
- package/dist/theme-editor.d.ts +46 -3
- package/package.json +1 -1
- package/src/client.tool-pair-replay.test.ts +325 -0
- package/src/client.ts +32 -31
- package/src/generated/runtype-openapi-contract.ts +26 -11
- package/src/session.ts +32 -3
- package/src/types.ts +43 -1
- package/src/utils/tool-pair-replay.ts +100 -0
package/dist/theme-editor.d.cts
CHANGED
|
@@ -1702,6 +1702,7 @@ type RuntypeExecutionStreamEvent = ({
|
|
|
1702
1702
|
turnIndex?: number;
|
|
1703
1703
|
type: "turn_start";
|
|
1704
1704
|
}) | ({
|
|
1705
|
+
attributes?: Record<string, string | number | boolean>;
|
|
1705
1706
|
completedAt?: string;
|
|
1706
1707
|
content?: string;
|
|
1707
1708
|
cost?: number;
|
|
@@ -1760,7 +1761,8 @@ type RuntypeExecutionStreamEvent = ({
|
|
|
1760
1761
|
};
|
|
1761
1762
|
turnId?: string;
|
|
1762
1763
|
type: "model_call_start";
|
|
1763
|
-
} | {
|
|
1764
|
+
} | ({
|
|
1765
|
+
attributes?: Record<string, string | number | boolean>;
|
|
1764
1766
|
completedAt?: string;
|
|
1765
1767
|
cost?: number;
|
|
1766
1768
|
durationMs?: number;
|
|
@@ -1784,10 +1786,11 @@ type RuntypeExecutionStreamEvent = ({
|
|
|
1784
1786
|
cacheWrite?: number;
|
|
1785
1787
|
input: number;
|
|
1786
1788
|
output: number;
|
|
1789
|
+
reasoning?: number;
|
|
1787
1790
|
};
|
|
1788
1791
|
turnId?: string;
|
|
1789
1792
|
type: "model_call_complete";
|
|
1790
|
-
} | {
|
|
1793
|
+
}) | {
|
|
1791
1794
|
executionId: string;
|
|
1792
1795
|
id: string;
|
|
1793
1796
|
index?: number;
|
|
@@ -2887,10 +2890,32 @@ type AgentWidgetContextMentionTokenRenderContext = {
|
|
|
2887
2890
|
ref: AgentWidgetContextMentionRef;
|
|
2888
2891
|
readonly: boolean;
|
|
2889
2892
|
};
|
|
2893
|
+
/** One earlier client-tool call replayed to the model, in `/v1/dispatch` shape. */
|
|
2894
|
+
type AgentWidgetReplayedToolCall = {
|
|
2895
|
+
toolCallId: string;
|
|
2896
|
+
/** Model-facing (sanitized) name, e.g. `webmcp_search`. */
|
|
2897
|
+
toolName: string;
|
|
2898
|
+
args: Record<string, unknown>;
|
|
2899
|
+
};
|
|
2900
|
+
/** The browser's answer to an {@link AgentWidgetReplayedToolCall}. */
|
|
2901
|
+
type AgentWidgetReplayedToolResult = {
|
|
2902
|
+
toolCallId: string;
|
|
2903
|
+
toolName: string;
|
|
2904
|
+
result: unknown;
|
|
2905
|
+
};
|
|
2906
|
+
/**
|
|
2907
|
+
* A message in the proxy/agent request payload. Earlier client-tool calls the
|
|
2908
|
+
* browser answered replay as an assistant message carrying `toolCalls`,
|
|
2909
|
+
* followed by a `tool` message carrying the matching `toolResults`; both have
|
|
2910
|
+
* empty `content`. Client-token mode never sends them: the server replays
|
|
2911
|
+
* from its own stored transcript there.
|
|
2912
|
+
*/
|
|
2890
2913
|
type AgentWidgetRequestPayloadMessage = {
|
|
2891
|
-
role: AgentWidgetMessageRole;
|
|
2914
|
+
role: AgentWidgetMessageRole | "tool";
|
|
2892
2915
|
content: MessageContent;
|
|
2893
2916
|
createdAt: string;
|
|
2917
|
+
toolCalls?: AgentWidgetReplayedToolCall[];
|
|
2918
|
+
toolResults?: AgentWidgetReplayedToolResult[];
|
|
2894
2919
|
};
|
|
2895
2920
|
type AgentWidgetRequestPayload = {
|
|
2896
2921
|
messages: AgentWidgetRequestPayloadMessage[];
|
|
@@ -3253,6 +3278,24 @@ type AgentMessageMetadata = {
|
|
|
3253
3278
|
* never re-resume the call.
|
|
3254
3279
|
*/
|
|
3255
3280
|
suggestRepliesResolved?: boolean;
|
|
3281
|
+
/**
|
|
3282
|
+
* The client-tool answer the server accepted through `/resume`, kept so
|
|
3283
|
+
* later proxy/agent turns can replay the call and its result to the model.
|
|
3284
|
+
* `toolName` is the widget's internal name (`webmcp:<name>` or a bare
|
|
3285
|
+
* built-in). Set only after the resume succeeds; preserved across
|
|
3286
|
+
* re-emissions of the tool message.
|
|
3287
|
+
*/
|
|
3288
|
+
clientToolAnswer?: {
|
|
3289
|
+
toolCallId: string;
|
|
3290
|
+
toolName: string;
|
|
3291
|
+
args: unknown;
|
|
3292
|
+
result: unknown;
|
|
3293
|
+
/**
|
|
3294
|
+
* Shared by the answers posted in one `/resume`, i.e. one assistant
|
|
3295
|
+
* turn's parallel calls; a chained call gets a new batch.
|
|
3296
|
+
*/
|
|
3297
|
+
batch?: string;
|
|
3298
|
+
};
|
|
3256
3299
|
};
|
|
3257
3300
|
type AgentWidgetRequestMiddlewareContext = {
|
|
3258
3301
|
payload: AgentWidgetRequestPayload;
|
package/dist/theme-editor.d.ts
CHANGED
|
@@ -1702,6 +1702,7 @@ type RuntypeExecutionStreamEvent = ({
|
|
|
1702
1702
|
turnIndex?: number;
|
|
1703
1703
|
type: "turn_start";
|
|
1704
1704
|
}) | ({
|
|
1705
|
+
attributes?: Record<string, string | number | boolean>;
|
|
1705
1706
|
completedAt?: string;
|
|
1706
1707
|
content?: string;
|
|
1707
1708
|
cost?: number;
|
|
@@ -1760,7 +1761,8 @@ type RuntypeExecutionStreamEvent = ({
|
|
|
1760
1761
|
};
|
|
1761
1762
|
turnId?: string;
|
|
1762
1763
|
type: "model_call_start";
|
|
1763
|
-
} | {
|
|
1764
|
+
} | ({
|
|
1765
|
+
attributes?: Record<string, string | number | boolean>;
|
|
1764
1766
|
completedAt?: string;
|
|
1765
1767
|
cost?: number;
|
|
1766
1768
|
durationMs?: number;
|
|
@@ -1784,10 +1786,11 @@ type RuntypeExecutionStreamEvent = ({
|
|
|
1784
1786
|
cacheWrite?: number;
|
|
1785
1787
|
input: number;
|
|
1786
1788
|
output: number;
|
|
1789
|
+
reasoning?: number;
|
|
1787
1790
|
};
|
|
1788
1791
|
turnId?: string;
|
|
1789
1792
|
type: "model_call_complete";
|
|
1790
|
-
} | {
|
|
1793
|
+
}) | {
|
|
1791
1794
|
executionId: string;
|
|
1792
1795
|
id: string;
|
|
1793
1796
|
index?: number;
|
|
@@ -2887,10 +2890,32 @@ type AgentWidgetContextMentionTokenRenderContext = {
|
|
|
2887
2890
|
ref: AgentWidgetContextMentionRef;
|
|
2888
2891
|
readonly: boolean;
|
|
2889
2892
|
};
|
|
2893
|
+
/** One earlier client-tool call replayed to the model, in `/v1/dispatch` shape. */
|
|
2894
|
+
type AgentWidgetReplayedToolCall = {
|
|
2895
|
+
toolCallId: string;
|
|
2896
|
+
/** Model-facing (sanitized) name, e.g. `webmcp_search`. */
|
|
2897
|
+
toolName: string;
|
|
2898
|
+
args: Record<string, unknown>;
|
|
2899
|
+
};
|
|
2900
|
+
/** The browser's answer to an {@link AgentWidgetReplayedToolCall}. */
|
|
2901
|
+
type AgentWidgetReplayedToolResult = {
|
|
2902
|
+
toolCallId: string;
|
|
2903
|
+
toolName: string;
|
|
2904
|
+
result: unknown;
|
|
2905
|
+
};
|
|
2906
|
+
/**
|
|
2907
|
+
* A message in the proxy/agent request payload. Earlier client-tool calls the
|
|
2908
|
+
* browser answered replay as an assistant message carrying `toolCalls`,
|
|
2909
|
+
* followed by a `tool` message carrying the matching `toolResults`; both have
|
|
2910
|
+
* empty `content`. Client-token mode never sends them: the server replays
|
|
2911
|
+
* from its own stored transcript there.
|
|
2912
|
+
*/
|
|
2890
2913
|
type AgentWidgetRequestPayloadMessage = {
|
|
2891
|
-
role: AgentWidgetMessageRole;
|
|
2914
|
+
role: AgentWidgetMessageRole | "tool";
|
|
2892
2915
|
content: MessageContent;
|
|
2893
2916
|
createdAt: string;
|
|
2917
|
+
toolCalls?: AgentWidgetReplayedToolCall[];
|
|
2918
|
+
toolResults?: AgentWidgetReplayedToolResult[];
|
|
2894
2919
|
};
|
|
2895
2920
|
type AgentWidgetRequestPayload = {
|
|
2896
2921
|
messages: AgentWidgetRequestPayloadMessage[];
|
|
@@ -3253,6 +3278,24 @@ type AgentMessageMetadata = {
|
|
|
3253
3278
|
* never re-resume the call.
|
|
3254
3279
|
*/
|
|
3255
3280
|
suggestRepliesResolved?: boolean;
|
|
3281
|
+
/**
|
|
3282
|
+
* The client-tool answer the server accepted through `/resume`, kept so
|
|
3283
|
+
* later proxy/agent turns can replay the call and its result to the model.
|
|
3284
|
+
* `toolName` is the widget's internal name (`webmcp:<name>` or a bare
|
|
3285
|
+
* built-in). Set only after the resume succeeds; preserved across
|
|
3286
|
+
* re-emissions of the tool message.
|
|
3287
|
+
*/
|
|
3288
|
+
clientToolAnswer?: {
|
|
3289
|
+
toolCallId: string;
|
|
3290
|
+
toolName: string;
|
|
3291
|
+
args: unknown;
|
|
3292
|
+
result: unknown;
|
|
3293
|
+
/**
|
|
3294
|
+
* Shared by the answers posted in one `/resume`, i.e. one assistant
|
|
3295
|
+
* turn's parallel calls; a chained call gets a new batch.
|
|
3296
|
+
*/
|
|
3297
|
+
batch?: string;
|
|
3298
|
+
};
|
|
3256
3299
|
};
|
|
3257
3300
|
type AgentWidgetRequestMiddlewareContext = {
|
|
3258
3301
|
payload: AgentWidgetRequestPayload;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@runtypelabs/persona",
|
|
3
|
-
"version": "4.23.
|
|
3
|
+
"version": "4.23.2",
|
|
4
4
|
"description": "Themeable, pluggable streaming agent widget for websites, in plain JS with support for voice input and reasoning / tool output.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.cjs",
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { AgentWidgetClient } from "./client";
|
|
4
|
+
import { AgentWidgetSession } from "./session";
|
|
5
|
+
import type { AgentWidgetMessage, ClientToolDefinition } from "./types";
|
|
6
|
+
import { modelFacingToolName } from "./utils/tool-pair-replay";
|
|
7
|
+
|
|
8
|
+
const SEARCH_TOOL: ClientToolDefinition = {
|
|
9
|
+
name: "search_catalog",
|
|
10
|
+
description: "Search the catalog",
|
|
11
|
+
origin: "webmcp",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const RESULT = { content: [{ type: "text", text: "SKU-42 costs $19" }] };
|
|
15
|
+
|
|
16
|
+
const user = (id: string, content: string, at: number): AgentWidgetMessage => ({
|
|
17
|
+
id,
|
|
18
|
+
role: "user",
|
|
19
|
+
content,
|
|
20
|
+
createdAt: new Date(at).toISOString(),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const assistant = (id: string, content: string, at: number): AgentWidgetMessage => ({
|
|
24
|
+
id,
|
|
25
|
+
role: "assistant",
|
|
26
|
+
content,
|
|
27
|
+
createdAt: new Date(at).toISOString(),
|
|
28
|
+
variant: "assistant",
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const answeredTool = (
|
|
32
|
+
toolCallId: string,
|
|
33
|
+
at: number,
|
|
34
|
+
overrides: Partial<NonNullable<AgentWidgetMessage["agentMetadata"]>> = {},
|
|
35
|
+
): AgentWidgetMessage => ({
|
|
36
|
+
id: `tool-${toolCallId}`,
|
|
37
|
+
role: "assistant",
|
|
38
|
+
content: "",
|
|
39
|
+
createdAt: new Date(at).toISOString(),
|
|
40
|
+
variant: "tool",
|
|
41
|
+
toolCall: {
|
|
42
|
+
id: toolCallId,
|
|
43
|
+
name: "webmcp:search_catalog",
|
|
44
|
+
status: "complete",
|
|
45
|
+
args: { q: "sku 42" },
|
|
46
|
+
result: RESULT,
|
|
47
|
+
},
|
|
48
|
+
agentMetadata: {
|
|
49
|
+
executionId: "exec-1",
|
|
50
|
+
awaitingLocalTool: false,
|
|
51
|
+
webMcpToolCallId: toolCallId,
|
|
52
|
+
clientToolAnswer: {
|
|
53
|
+
toolCallId,
|
|
54
|
+
toolName: "webmcp:search_catalog",
|
|
55
|
+
args: { q: "sku 42" },
|
|
56
|
+
result: RESULT,
|
|
57
|
+
},
|
|
58
|
+
...overrides,
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
/** Turn 1 (with its answered tool) followed by turn 2's user message. */
|
|
63
|
+
const twoTurnTranscript = (): AgentWidgetMessage[] => [
|
|
64
|
+
user("u1", "What does SKU 42 cost?", 1_000),
|
|
65
|
+
answeredTool("toolu_01", 2_000),
|
|
66
|
+
assistant("a1", "SKU 42 costs $19.", 3_000),
|
|
67
|
+
user("u2", "And what was the SKU again?", 4_000),
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
function sse(): Response {
|
|
71
|
+
const encoder = new TextEncoder();
|
|
72
|
+
const stream = new ReadableStream({
|
|
73
|
+
start(c) {
|
|
74
|
+
c.enqueue(encoder.encode('data: {"type":"done"}\n\n'));
|
|
75
|
+
c.close();
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
return new Response(stream, { headers: { "Content-Type": "text/event-stream" } });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let bodies: Array<Record<string, unknown>>;
|
|
82
|
+
beforeEach(() => {
|
|
83
|
+
bodies = [];
|
|
84
|
+
global.fetch = vi.fn().mockImplementation(async (_url: string, init: { body: string }) => {
|
|
85
|
+
bodies.push(JSON.parse(init.body));
|
|
86
|
+
return sse();
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const withTools = (client: AgentWidgetClient, tools: ClientToolDefinition[]) => {
|
|
91
|
+
(client as unknown as { webMcpBridge: { snapshotForDispatch: () => unknown[] } }).webMcpBridge = {
|
|
92
|
+
snapshotForDispatch: () => tools,
|
|
93
|
+
};
|
|
94
|
+
return client;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const EXPECTED_TURN_2 = [
|
|
98
|
+
{ role: "user", content: "What does SKU 42 cost?" },
|
|
99
|
+
{
|
|
100
|
+
role: "assistant",
|
|
101
|
+
content: "",
|
|
102
|
+
toolCalls: [{ toolCallId: "toolu_01", toolName: "webmcp_search_catalog", args: { q: "sku 42" } }],
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
role: "tool",
|
|
106
|
+
content: "",
|
|
107
|
+
toolResults: [{ toolCallId: "toolu_01", toolName: "webmcp_search_catalog", result: RESULT }],
|
|
108
|
+
},
|
|
109
|
+
{ role: "assistant", content: "SKU 42 costs $19." },
|
|
110
|
+
{ role: "user", content: "And what was the SKU again?" },
|
|
111
|
+
];
|
|
112
|
+
|
|
113
|
+
const stripCreatedAt = (messages: unknown) =>
|
|
114
|
+
(messages as Array<Record<string, unknown>>).map(({ createdAt: _createdAt, ...rest }) => rest);
|
|
115
|
+
|
|
116
|
+
describe("tool-pair replay: proxy mode", () => {
|
|
117
|
+
it("sends turn 1's answered call and result, paired and in order, on turn 2", async () => {
|
|
118
|
+
const client = withTools(new AgentWidgetClient({ apiUrl: "http://proxy.test/chat" }), [SEARCH_TOOL]);
|
|
119
|
+
await client.dispatch({ messages: twoTurnTranscript() }, () => undefined);
|
|
120
|
+
expect(stripCreatedAt(bodies[0]!.messages)).toEqual(EXPECTED_TURN_2);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("keeps the pair after the page stops offering the tool", async () => {
|
|
124
|
+
const client = withTools(new AgentWidgetClient({ apiUrl: "http://proxy.test/chat" }), []);
|
|
125
|
+
await client.dispatch({ messages: twoTurnTranscript() }, () => undefined);
|
|
126
|
+
expect(stripCreatedAt(bodies[0]!.messages)).toEqual(EXPECTED_TURN_2);
|
|
127
|
+
expect(bodies[0]!.clientTools).toBeUndefined();
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe("tool-pair replay: agent mode", () => {
|
|
132
|
+
it("sends turn 1's answered call and result, paired and in order, on turn 2", async () => {
|
|
133
|
+
const client = withTools(
|
|
134
|
+
new AgentWidgetClient({
|
|
135
|
+
apiUrl: "http://proxy.test/agents",
|
|
136
|
+
agent: { name: "Test", model: "openai:gpt-4o-mini", systemPrompt: "test" },
|
|
137
|
+
}),
|
|
138
|
+
[SEARCH_TOOL],
|
|
139
|
+
);
|
|
140
|
+
await client.dispatch({ messages: twoTurnTranscript() }, () => undefined);
|
|
141
|
+
expect(stripCreatedAt(bodies[0]!.messages)).toEqual(EXPECTED_TURN_2);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("tool-pair replay: bubbles that must not replay", () => {
|
|
146
|
+
it("a tool bubble with no accepted answer emits nothing", async () => {
|
|
147
|
+
const pending: AgentWidgetMessage = {
|
|
148
|
+
...answeredTool("toolu_02", 2_000),
|
|
149
|
+
toolCall: { id: "toolu_02", name: "webmcp:search_catalog", status: "running", args: {} },
|
|
150
|
+
agentMetadata: { executionId: "exec-1", awaitingLocalTool: true, webMcpToolCallId: "toolu_02" },
|
|
151
|
+
};
|
|
152
|
+
const client = withTools(new AgentWidgetClient({ apiUrl: "http://proxy.test/chat" }), [SEARCH_TOOL]);
|
|
153
|
+
await client.dispatch(
|
|
154
|
+
{ messages: [user("u1", "hi", 1_000), pending, user("u2", "again", 3_000)] },
|
|
155
|
+
() => undefined,
|
|
156
|
+
);
|
|
157
|
+
const messages = bodies[0]!.messages as Array<Record<string, unknown>>;
|
|
158
|
+
expect(messages.map((m) => m.role)).toEqual(["user", "user"]);
|
|
159
|
+
expect(messages.some((m) => "toolCalls" in m || "toolResults" in m)).toBe(false);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("a server-side tool's result stays out (only browser-answered calls replay)", async () => {
|
|
163
|
+
const serverTool: AgentWidgetMessage = {
|
|
164
|
+
...answeredTool("toolu_03", 2_000),
|
|
165
|
+
agentMetadata: { executionId: "exec-1" },
|
|
166
|
+
};
|
|
167
|
+
const client = withTools(new AgentWidgetClient({ apiUrl: "http://proxy.test/chat" }), [SEARCH_TOOL]);
|
|
168
|
+
await client.dispatch(
|
|
169
|
+
{ messages: [user("u1", "hi", 1_000), serverTool, user("u2", "again", 3_000)] },
|
|
170
|
+
() => undefined,
|
|
171
|
+
);
|
|
172
|
+
expect((bodies[0]!.messages as unknown[]).length).toBe(2);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe("tool-pair replay: parallel and chained calls", () => {
|
|
177
|
+
type Sent = Array<{
|
|
178
|
+
role: string;
|
|
179
|
+
toolCalls?: Array<{ toolCallId: string }>;
|
|
180
|
+
toolResults?: Array<{ toolCallId: string }>;
|
|
181
|
+
}>;
|
|
182
|
+
const inBatch = (toolCallId: string, at: number, batch: string) => {
|
|
183
|
+
const message = answeredTool(toolCallId, at);
|
|
184
|
+
message.agentMetadata!.clientToolAnswer!.batch = batch;
|
|
185
|
+
return message;
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
it("groups answers from one /resume batch into one call message and one result message", async () => {
|
|
189
|
+
const client = withTools(new AgentWidgetClient({ apiUrl: "http://proxy.test/chat" }), [SEARCH_TOOL]);
|
|
190
|
+
await client.dispatch(
|
|
191
|
+
{
|
|
192
|
+
messages: [
|
|
193
|
+
user("u1", "compare", 1_000),
|
|
194
|
+
inBatch("toolu_a", 2_000, "b1"),
|
|
195
|
+
inBatch("toolu_b", 2_001, "b1"),
|
|
196
|
+
assistant("a1", "done", 3_000),
|
|
197
|
+
],
|
|
198
|
+
},
|
|
199
|
+
() => undefined,
|
|
200
|
+
);
|
|
201
|
+
const messages = bodies[0]!.messages as Sent;
|
|
202
|
+
expect(messages.map((m) => m.role)).toEqual(["user", "assistant", "tool", "assistant"]);
|
|
203
|
+
expect(messages[1]!.toolCalls!.map((c) => c.toolCallId)).toEqual(["toolu_a", "toolu_b"]);
|
|
204
|
+
expect(messages[2]!.toolResults!.map((r) => r.toolCallId)).toEqual(["toolu_a", "toolu_b"]);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("keeps a chained call (a later /resume batch, no text between) as its own pair", async () => {
|
|
208
|
+
const client = withTools(new AgentWidgetClient({ apiUrl: "http://proxy.test/chat" }), [SEARCH_TOOL]);
|
|
209
|
+
await client.dispatch(
|
|
210
|
+
{
|
|
211
|
+
messages: [
|
|
212
|
+
user("u1", "look it up, then look up the related item", 1_000),
|
|
213
|
+
inBatch("toolu_a", 2_000, "b1"),
|
|
214
|
+
inBatch("toolu_b", 2_500, "b2"),
|
|
215
|
+
assistant("a1", "done", 3_000),
|
|
216
|
+
],
|
|
217
|
+
},
|
|
218
|
+
() => undefined,
|
|
219
|
+
);
|
|
220
|
+
const messages = bodies[0]!.messages as Sent;
|
|
221
|
+
expect(messages.map((m) => m.role)).toEqual(["user", "assistant", "tool", "assistant", "tool", "assistant"]);
|
|
222
|
+
expect(messages[1]!.toolCalls!.map((c) => c.toolCallId)).toEqual(["toolu_a"]);
|
|
223
|
+
expect(messages[3]!.toolCalls!.map((c) => c.toolCallId)).toEqual(["toolu_b"]);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe("tool-pair replay: requestMiddleware", () => {
|
|
228
|
+
it("keeps replayed pairs when the middleware removes the tools", async () => {
|
|
229
|
+
const client = withTools(
|
|
230
|
+
new AgentWidgetClient({
|
|
231
|
+
apiUrl: "http://proxy.test/chat",
|
|
232
|
+
requestMiddleware: ({ payload }) => ({ ...payload, clientTools: undefined }),
|
|
233
|
+
}),
|
|
234
|
+
[SEARCH_TOOL],
|
|
235
|
+
);
|
|
236
|
+
await client.dispatch({ messages: twoTurnTranscript() }, () => undefined);
|
|
237
|
+
expect(stripCreatedAt(bodies[0]!.messages)).toEqual(EXPECTED_TURN_2);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe("tool-pair replay: client-token mode is server-owned", () => {
|
|
242
|
+
it("sends no toolCalls, toolResults or tool role on /client/chat", async () => {
|
|
243
|
+
const client = withTools(
|
|
244
|
+
new AgentWidgetClient({ clientToken: "ct_live_demo", apiUrl: "https://api.runtype.com" }),
|
|
245
|
+
[SEARCH_TOOL],
|
|
246
|
+
);
|
|
247
|
+
(client as unknown as { clientSession: { sessionId: string; expiresAt: Date } }).clientSession = {
|
|
248
|
+
sessionId: "cs_1",
|
|
249
|
+
expiresAt: new Date(Date.now() + 600_000),
|
|
250
|
+
};
|
|
251
|
+
await client.dispatch({ messages: twoTurnTranscript(), assistantMessageId: "a2" }, () => undefined);
|
|
252
|
+
const messages = bodies[0]!.messages as Array<Record<string, unknown>>;
|
|
253
|
+
expect(messages.map((m) => m.role)).toEqual(["user", "assistant", "user"]);
|
|
254
|
+
expect(messages.some((m) => "toolCalls" in m || "toolResults" in m)).toBe(false);
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
describe("modelFacingToolName", () => {
|
|
259
|
+
it("matches core's AI-SDK sanitizer", () => {
|
|
260
|
+
expect(modelFacingToolName("webmcp:get_schema")).toBe("webmcp_get_schema");
|
|
261
|
+
expect(modelFacingToolName("webmcp:Get-Slide")).toBe("webmcp_get_slide");
|
|
262
|
+
expect(modelFacingToolName("suggest_replies")).toBe("suggest_replies");
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
describe("session records the accepted client-tool answer", () => {
|
|
267
|
+
const makeSession = (resumeStatus = 200) => {
|
|
268
|
+
const session = new AgentWidgetSession(
|
|
269
|
+
{ apiUrl: "http://test", webmcp: { enabled: true } },
|
|
270
|
+
{
|
|
271
|
+
onMessagesChanged: () => undefined,
|
|
272
|
+
onStatusChanged: () => undefined,
|
|
273
|
+
onStreamingChanged: () => undefined,
|
|
274
|
+
},
|
|
275
|
+
);
|
|
276
|
+
const client = (session as unknown as { client: Record<string, unknown> }).client;
|
|
277
|
+
client.executeWebMcpToolCall = vi.fn(async () => RESULT);
|
|
278
|
+
client.isWebMcpOperational = vi.fn(() => true);
|
|
279
|
+
client.resumeFlow = vi.fn(async () => new Response(new Blob([""]), { status: resumeStatus }));
|
|
280
|
+
(session as unknown as { connectStream: () => Promise<void> }).connectStream = vi.fn(async () => undefined);
|
|
281
|
+
return session;
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
const awaiting = (): AgentWidgetMessage => ({
|
|
285
|
+
id: "tool-toolu_01",
|
|
286
|
+
role: "assistant",
|
|
287
|
+
content: "",
|
|
288
|
+
createdAt: new Date().toISOString(),
|
|
289
|
+
variant: "tool",
|
|
290
|
+
agentMetadata: { executionId: "exec-1", awaitingLocalTool: true, webMcpToolCallId: "toolu_01" },
|
|
291
|
+
toolCall: { id: "toolu_01", name: "webmcp:search_catalog", status: "running", args: { q: "sku 42" } },
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
const stored = (session: AgentWidgetSession) =>
|
|
295
|
+
(session as unknown as { messages: AgentWidgetMessage[] }).messages.find((m) => m.id === "tool-toolu_01");
|
|
296
|
+
|
|
297
|
+
it("stores the answer after /resume succeeds and keeps it across a tool_complete re-emit", async () => {
|
|
298
|
+
const session = makeSession();
|
|
299
|
+
await session.resolveWebMcpToolCall(awaiting());
|
|
300
|
+
expect(stored(session)?.agentMetadata?.clientToolAnswer).toEqual({
|
|
301
|
+
toolCallId: "toolu_01",
|
|
302
|
+
toolName: "webmcp:search_catalog",
|
|
303
|
+
args: { q: "sku 42" },
|
|
304
|
+
result: RESULT,
|
|
305
|
+
batch: expect.any(String),
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
(session as unknown as { upsertMessage: (m: AgentWidgetMessage) => void }).upsertMessage({
|
|
309
|
+
id: "tool-toolu_01",
|
|
310
|
+
role: "assistant",
|
|
311
|
+
content: "",
|
|
312
|
+
createdAt: new Date().toISOString(),
|
|
313
|
+
variant: "tool",
|
|
314
|
+
agentMetadata: { executionId: "exec-1" },
|
|
315
|
+
toolCall: { id: "toolu_01", name: "webmcp:search_catalog", status: "complete", result: "server copy" },
|
|
316
|
+
});
|
|
317
|
+
expect(stored(session)?.agentMetadata?.clientToolAnswer?.result).toEqual(RESULT);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("stores nothing when /resume fails", async () => {
|
|
321
|
+
const session = makeSession(500);
|
|
322
|
+
await session.resolveWebMcpToolCall(awaiting());
|
|
323
|
+
expect(stored(session)?.agentMetadata?.clientToolAnswer).toBeUndefined();
|
|
324
|
+
});
|
|
325
|
+
});
|
package/src/client.ts
CHANGED
|
@@ -49,6 +49,7 @@ import {
|
|
|
49
49
|
import { resolveTarget } from "./utils/target";
|
|
50
50
|
import { generateTurnId } from "./utils/message-id";
|
|
51
51
|
import { builtInClientToolsForDispatch } from "./ask-user-question-tool";
|
|
52
|
+
import { serializeWithToolPairs } from "./utils/tool-pair-replay";
|
|
52
53
|
import {
|
|
53
54
|
extractTextFromJson,
|
|
54
55
|
createPlainTextParser,
|
|
@@ -221,6 +222,18 @@ const hasValidContent = (message: AgentWidgetMessage): boolean => {
|
|
|
221
222
|
return false;
|
|
222
223
|
};
|
|
223
224
|
|
|
225
|
+
const sortByCreatedAt = (messages: AgentWidgetMessage[]): AgentWidgetMessage[] =>
|
|
226
|
+
messages
|
|
227
|
+
.slice()
|
|
228
|
+
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
|
229
|
+
|
|
230
|
+
// Priority: contentParts (multi-modal) > llmContent (explicit LLM content) > rawContent (structured parsers) > content (display)
|
|
231
|
+
const toPayloadMessage = (message: AgentWidgetMessage) => ({
|
|
232
|
+
role: message.role,
|
|
233
|
+
content: message.contentParts ?? message.llmContent ?? message.rawContent ?? message.content,
|
|
234
|
+
createdAt: message.createdAt
|
|
235
|
+
});
|
|
236
|
+
|
|
224
237
|
/**
|
|
225
238
|
* Maps parserType string to the corresponding parser factory function
|
|
226
239
|
*/
|
|
@@ -2533,22 +2546,17 @@ export class AgentWidgetClient {
|
|
|
2533
2546
|
throw new Error('Agent configuration required for agent mode');
|
|
2534
2547
|
}
|
|
2535
2548
|
|
|
2536
|
-
// Filter out messages with empty content and normalize
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
.map((message) => ({
|
|
2548
|
-
role: message.role,
|
|
2549
|
-
content: message.contentParts ?? message.llmContent ?? message.rawContent ?? message.content,
|
|
2550
|
-
createdAt: message.createdAt
|
|
2551
|
-
}));
|
|
2549
|
+
// Filter out messages with empty content and normalize; answered
|
|
2550
|
+
// client-tool calls replay as paired toolCalls/toolResults messages.
|
|
2551
|
+
const normalizedMessages = serializeWithToolPairs(
|
|
2552
|
+
sortByCreatedAt(messages),
|
|
2553
|
+
(message) =>
|
|
2554
|
+
hasValidContent(message) &&
|
|
2555
|
+
(message.role === "user" || message.role === "assistant" || message.role === "system") &&
|
|
2556
|
+
(!message.variant || message.variant === "assistant")
|
|
2557
|
+
? toPayloadMessage(message)
|
|
2558
|
+
: null
|
|
2559
|
+
);
|
|
2552
2560
|
|
|
2553
2561
|
const composer = this.normalizeComposerOptions(composerOptions);
|
|
2554
2562
|
const payload: AgentWidgetAgentRequestPayload = {
|
|
@@ -2590,21 +2598,14 @@ export class AgentWidgetClient {
|
|
|
2590
2598
|
messages: AgentWidgetMessage[],
|
|
2591
2599
|
composerOptions?: ComposerOptionsPayload
|
|
2592
2600
|
): Promise<AgentWidgetRequestPayload> {
|
|
2593
|
-
// Filter out messages with empty content to prevent validation errors
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
})
|
|
2602
|
-
.map((message) => ({
|
|
2603
|
-
role: message.role,
|
|
2604
|
-
// Priority: contentParts (multi-modal) > llmContent (explicit LLM content) > rawContent (structured parsers) > content (display)
|
|
2605
|
-
content: message.contentParts ?? message.llmContent ?? message.rawContent ?? message.content,
|
|
2606
|
-
createdAt: message.createdAt
|
|
2607
|
-
}));
|
|
2601
|
+
// Filter out messages with empty content to prevent validation errors;
|
|
2602
|
+
// answered client-tool calls replay as paired toolCalls/toolResults
|
|
2603
|
+
// messages. Client-token mode maps `options.messages` itself and never
|
|
2604
|
+
// reads these, so the server stays the replay owner there.
|
|
2605
|
+
const normalizedMessages = serializeWithToolPairs(
|
|
2606
|
+
sortByCreatedAt(messages),
|
|
2607
|
+
(message) => (hasValidContent(message) ? toPayloadMessage(message) : null)
|
|
2608
|
+
);
|
|
2608
2609
|
|
|
2609
2610
|
const routed = this.routing();
|
|
2610
2611
|
const payload: AgentWidgetRequestPayload = {
|