@runtypelabs/persona 4.6.0 → 4.7.0
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-8RICZWQe.d.cts → types-CSmiKRVa.d.cts} +5 -0
- package/dist/animations/{types-8RICZWQe.d.ts → types-CSmiKRVa.d.ts} +5 -0
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/codegen.cjs +4 -4
- package/dist/codegen.js +4 -4
- package/dist/index.cjs +39 -39
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +51 -3
- package/dist/index.d.ts +51 -3
- package/dist/index.global.js +29 -29
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +39 -39
- package/dist/index.js.map +1 -1
- package/dist/smart-dom-reader.d.cts +5 -0
- package/dist/smart-dom-reader.d.ts +5 -0
- package/dist/theme-editor-preview.cjs +30 -30
- package/dist/theme-editor-preview.d.cts +5 -0
- package/dist/theme-editor-preview.d.ts +5 -0
- package/dist/theme-editor-preview.js +30 -30
- package/dist/theme-editor.d.cts +5 -0
- package/dist/theme-editor.d.ts +5 -0
- package/package.json +4 -2
- package/src/client.test.ts +314 -0
- package/src/client.ts +200 -86
- package/src/generated/runtype-openapi-contract.ts +10 -1
- package/src/runtime/persist-state.test.ts +118 -0
- package/src/ui.scroll-additive.test.ts +1 -1
- package/src/ui.streaming-coalescing.test.ts +312 -0
- package/src/ui.ts +235 -105
- package/src/utils/morph.test.ts +47 -0
- package/src/utils/morph.ts +18 -0
package/dist/theme-editor.d.cts
CHANGED
|
@@ -783,6 +783,7 @@ type RuntypeExecutionStreamEvent = ({
|
|
|
783
783
|
};
|
|
784
784
|
type: "execution_complete";
|
|
785
785
|
}) | ({
|
|
786
|
+
blockReason?: string;
|
|
786
787
|
code?: string;
|
|
787
788
|
completedAt?: string;
|
|
788
789
|
error: string | {
|
|
@@ -1037,6 +1038,7 @@ type RuntypeExecutionStreamEvent = ({
|
|
|
1037
1038
|
startedAt?: string;
|
|
1038
1039
|
subagent?: {
|
|
1039
1040
|
agentName?: string;
|
|
1041
|
+
parentToolCallId?: string;
|
|
1040
1042
|
toolName: string;
|
|
1041
1043
|
};
|
|
1042
1044
|
timeout?: number;
|
|
@@ -1130,6 +1132,7 @@ type RuntypeFlowSSEEvent = {
|
|
|
1130
1132
|
totalTokensUsed?: number;
|
|
1131
1133
|
type: "flow_complete";
|
|
1132
1134
|
} | ({
|
|
1135
|
+
blockReason?: string;
|
|
1133
1136
|
code?: string;
|
|
1134
1137
|
error: string | {
|
|
1135
1138
|
code: string;
|
|
@@ -1150,6 +1153,7 @@ type RuntypeFlowSSEEvent = {
|
|
|
1150
1153
|
type: "flow_error";
|
|
1151
1154
|
upgradeUrl?: string;
|
|
1152
1155
|
}) | ({
|
|
1156
|
+
approvalId?: string;
|
|
1153
1157
|
awaitReason?: string;
|
|
1154
1158
|
awaitedAt: string;
|
|
1155
1159
|
crawlId?: string;
|
|
@@ -1160,6 +1164,7 @@ type RuntypeFlowSSEEvent = {
|
|
|
1160
1164
|
parameters?: Record<string, unknown>;
|
|
1161
1165
|
seq?: number;
|
|
1162
1166
|
stepId?: string;
|
|
1167
|
+
timeout?: number;
|
|
1163
1168
|
toolCallId?: string;
|
|
1164
1169
|
toolId?: string;
|
|
1165
1170
|
toolName?: string;
|
package/dist/theme-editor.d.ts
CHANGED
|
@@ -783,6 +783,7 @@ type RuntypeExecutionStreamEvent = ({
|
|
|
783
783
|
};
|
|
784
784
|
type: "execution_complete";
|
|
785
785
|
}) | ({
|
|
786
|
+
blockReason?: string;
|
|
786
787
|
code?: string;
|
|
787
788
|
completedAt?: string;
|
|
788
789
|
error: string | {
|
|
@@ -1037,6 +1038,7 @@ type RuntypeExecutionStreamEvent = ({
|
|
|
1037
1038
|
startedAt?: string;
|
|
1038
1039
|
subagent?: {
|
|
1039
1040
|
agentName?: string;
|
|
1041
|
+
parentToolCallId?: string;
|
|
1040
1042
|
toolName: string;
|
|
1041
1043
|
};
|
|
1042
1044
|
timeout?: number;
|
|
@@ -1130,6 +1132,7 @@ type RuntypeFlowSSEEvent = {
|
|
|
1130
1132
|
totalTokensUsed?: number;
|
|
1131
1133
|
type: "flow_complete";
|
|
1132
1134
|
} | ({
|
|
1135
|
+
blockReason?: string;
|
|
1133
1136
|
code?: string;
|
|
1134
1137
|
error: string | {
|
|
1135
1138
|
code: string;
|
|
@@ -1150,6 +1153,7 @@ type RuntypeFlowSSEEvent = {
|
|
|
1150
1153
|
type: "flow_error";
|
|
1151
1154
|
upgradeUrl?: string;
|
|
1152
1155
|
}) | ({
|
|
1156
|
+
approvalId?: string;
|
|
1153
1157
|
awaitReason?: string;
|
|
1154
1158
|
awaitedAt: string;
|
|
1155
1159
|
crawlId?: string;
|
|
@@ -1160,6 +1164,7 @@ type RuntypeFlowSSEEvent = {
|
|
|
1160
1164
|
parameters?: Record<string, unknown>;
|
|
1161
1165
|
seq?: number;
|
|
1162
1166
|
stepId?: string;
|
|
1167
|
+
timeout?: number;
|
|
1163
1168
|
toolCallId?: string;
|
|
1164
1169
|
toolId?: string;
|
|
1165
1170
|
toolName?: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@runtypelabs/persona",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.7.0",
|
|
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",
|
|
@@ -79,6 +79,7 @@
|
|
|
79
79
|
},
|
|
80
80
|
"devDependencies": {
|
|
81
81
|
"@size-limit/file": "^12.1.0",
|
|
82
|
+
"acorn": "^8.15.0",
|
|
82
83
|
"@types/node": "^20.12.7",
|
|
83
84
|
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
|
84
85
|
"@typescript-eslint/parser": "^7.0.0",
|
|
@@ -120,7 +121,8 @@
|
|
|
120
121
|
"access": "public"
|
|
121
122
|
},
|
|
122
123
|
"scripts": {
|
|
123
|
-
"build": "rimraf dist && pnpm run build:styles && pnpm run build:markdown-parsers && pnpm run build:client && pnpm run build:installer && pnpm run build:launcher && pnpm run build:webmcp-polyfill && pnpm run build:runtype-tts && pnpm run build:theme-ref && pnpm run build:codegen && pnpm run build:theme-editor && pnpm run build:theme-editor-preview && pnpm run build:testing && pnpm run build:smart-dom-reader && pnpm run build:voice-worklet-player && pnpm run build:plugin-kit && pnpm run build:animations",
|
|
124
|
+
"build": "rimraf dist && pnpm run build:styles && pnpm run build:markdown-parsers && pnpm run build:client && pnpm run build:installer && pnpm run build:launcher && pnpm run build:webmcp-polyfill && pnpm run build:runtype-tts && pnpm run build:theme-ref && pnpm run build:codegen && pnpm run build:theme-editor && pnpm run build:theme-editor-preview && pnpm run build:testing && pnpm run build:smart-dom-reader && pnpm run build:voice-worklet-player && pnpm run build:plugin-kit && pnpm run build:animations && pnpm run check:dist-vite8",
|
|
125
|
+
"check:dist-vite8": "node scripts/check-dist-no-in-for-init.mjs",
|
|
124
126
|
"build:markdown-parsers": "tsup --config tsup.markdown-parsers.config.ts",
|
|
125
127
|
"build:plugin-kit": "tsup src/plugin-kit.ts --format esm,cjs --minify --dts --out-dir dist --no-splitting",
|
|
126
128
|
"build:theme-editor": "tsup src/theme-editor.ts --format esm,cjs --minify --dts --out-dir dist --no-splitting",
|
package/src/client.test.ts
CHANGED
|
@@ -5,6 +5,86 @@ import { createJsonStreamParser } from './utils/formatting';
|
|
|
5
5
|
import { VERSION } from './version';
|
|
6
6
|
import { createUnifiedEventWrite } from './utils/__fixtures__/unified-translator.oracle';
|
|
7
7
|
|
|
8
|
+
describe('AgentWidgetClient - dispatch abort signals', () => {
|
|
9
|
+
const message = {
|
|
10
|
+
id: 'usr_abort',
|
|
11
|
+
role: 'user' as const,
|
|
12
|
+
content: 'stop',
|
|
13
|
+
createdAt: '2026-07-11T00:00:00.000Z',
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
it.each([
|
|
17
|
+
['proxy', { apiUrl: 'https://api.example.com/chat' }],
|
|
18
|
+
[
|
|
19
|
+
'agent',
|
|
20
|
+
{
|
|
21
|
+
apiUrl: 'https://api.example.com/agents',
|
|
22
|
+
agent: { name: 'Test', model: 'openai:gpt-4o-mini', systemPrompt: 'test' },
|
|
23
|
+
},
|
|
24
|
+
],
|
|
25
|
+
[
|
|
26
|
+
'client token',
|
|
27
|
+
{ apiUrl: 'https://api.runtype.com', clientToken: 'ct_live_demo' },
|
|
28
|
+
],
|
|
29
|
+
] as const)(
|
|
30
|
+
'rejects a pre-aborted signal before starting %s dispatch work',
|
|
31
|
+
async (_mode, config) => {
|
|
32
|
+
const getHeaders = vi.fn(() => ({ Authorization: 'test' }));
|
|
33
|
+
const contextProvider = vi.fn(async () => ({ page: 'context' }));
|
|
34
|
+
const client = new AgentWidgetClient({
|
|
35
|
+
...config,
|
|
36
|
+
getHeaders,
|
|
37
|
+
contextProviders: [contextProvider],
|
|
38
|
+
});
|
|
39
|
+
const fetchSpy = vi.fn();
|
|
40
|
+
global.fetch = fetchSpy;
|
|
41
|
+
const events: AgentWidgetEvent[] = [];
|
|
42
|
+
const abortController = new AbortController();
|
|
43
|
+
abortController.abort();
|
|
44
|
+
|
|
45
|
+
await expect(
|
|
46
|
+
client.dispatch(
|
|
47
|
+
{ messages: [message], signal: abortController.signal },
|
|
48
|
+
(event) => events.push(event)
|
|
49
|
+
)
|
|
50
|
+
).rejects.toMatchObject({ name: 'AbortError' });
|
|
51
|
+
|
|
52
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
53
|
+
expect(getHeaders).not.toHaveBeenCalled();
|
|
54
|
+
expect(contextProvider).not.toHaveBeenCalled();
|
|
55
|
+
expect(events).toEqual([]);
|
|
56
|
+
}
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
it('still forwards an abort that occurs after dispatch starts', async () => {
|
|
60
|
+
const abortController = new AbortController();
|
|
61
|
+
let requestSignal: AbortSignal | undefined;
|
|
62
|
+
let markFetchStarted!: () => void;
|
|
63
|
+
const fetchStarted = new Promise<void>((resolve) => {
|
|
64
|
+
markFetchStarted = resolve;
|
|
65
|
+
});
|
|
66
|
+
global.fetch = vi.fn((_url: string | URL | Request, init?: RequestInit) => {
|
|
67
|
+
requestSignal = init?.signal ?? undefined;
|
|
68
|
+
markFetchStarted();
|
|
69
|
+
return new Promise<Response>((_resolve, reject) => {
|
|
70
|
+
requestSignal?.addEventListener('abort', () => reject(requestSignal?.reason));
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
const client = new AgentWidgetClient({ apiUrl: 'https://api.example.com/chat' });
|
|
74
|
+
const dispatch = client.dispatch(
|
|
75
|
+
{ messages: [message], signal: abortController.signal },
|
|
76
|
+
() => undefined
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
await fetchStarted;
|
|
80
|
+
abortController.abort();
|
|
81
|
+
|
|
82
|
+
await expect(dispatch).rejects.toMatchObject({ name: 'AbortError' });
|
|
83
|
+
expect(requestSignal).toBe(abortController.signal);
|
|
84
|
+
expect(requestSignal?.aborted).toBe(true);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
8
88
|
describe('AgentWidgetClient - Empty Message Filtering', () => {
|
|
9
89
|
let client: AgentWidgetClient;
|
|
10
90
|
let events: AgentWidgetEvent[] = [];
|
|
@@ -3845,6 +3925,240 @@ describe('AgentWidgetClient - diff-only clientTools (client-token)', () => {
|
|
|
3845
3925
|
});
|
|
3846
3926
|
});
|
|
3847
3927
|
|
|
3928
|
+
// ============================================================================
|
|
3929
|
+
// Mid-run clientTools refresh on resume (client-token mode, core#5361)
|
|
3930
|
+
// ============================================================================
|
|
3931
|
+
|
|
3932
|
+
describe('AgentWidgetClient - resumeFlow clientTools refresh (client-token)', () => {
|
|
3933
|
+
const TOOLS = [
|
|
3934
|
+
{ name: 'add_to_cart', description: 'Add to cart', origin: 'webmcp' as const },
|
|
3935
|
+
];
|
|
3936
|
+
|
|
3937
|
+
function sse(): Response {
|
|
3938
|
+
const encoder = new TextEncoder();
|
|
3939
|
+
const stream = new ReadableStream({
|
|
3940
|
+
start(c) {
|
|
3941
|
+
c.enqueue(encoder.encode('data: {"type":"done"}\n\n'));
|
|
3942
|
+
c.close();
|
|
3943
|
+
},
|
|
3944
|
+
});
|
|
3945
|
+
return new Response(stream, { headers: { 'Content-Type': 'text/event-stream' } });
|
|
3946
|
+
}
|
|
3947
|
+
|
|
3948
|
+
// A client-token client with a live session (so initSession short-circuits)
|
|
3949
|
+
// and a stubbed bridge whose snapshot is read lazily via `tools()`.
|
|
3950
|
+
function makeClient(tools: () => unknown[]) {
|
|
3951
|
+
const client = new AgentWidgetClient({
|
|
3952
|
+
clientToken: 'ct_live_demo',
|
|
3953
|
+
apiUrl: 'https://api.runtype.com',
|
|
3954
|
+
});
|
|
3955
|
+
(client as unknown as { clientSession: { sessionId: string; expiresAt: Date } }).clientSession = {
|
|
3956
|
+
sessionId: 'cs_resume_1',
|
|
3957
|
+
expiresAt: new Date(Date.now() + 600_000),
|
|
3958
|
+
};
|
|
3959
|
+
(client as unknown as { webMcpBridge: { snapshotForDispatch: () => unknown[] } | null }).webMcpBridge = {
|
|
3960
|
+
snapshotForDispatch: () => tools(),
|
|
3961
|
+
};
|
|
3962
|
+
return client;
|
|
3963
|
+
}
|
|
3964
|
+
|
|
3965
|
+
const userMsg = () => ({
|
|
3966
|
+
messages: [{ id: 'u1', role: 'user' as const, content: 'hi', createdAt: new Date().toISOString() }],
|
|
3967
|
+
assistantMessageId: 'a1',
|
|
3968
|
+
});
|
|
3969
|
+
|
|
3970
|
+
let chatBodies: Array<Record<string, unknown>>;
|
|
3971
|
+
let resumeBodies: Array<Record<string, unknown>>;
|
|
3972
|
+
beforeEach(() => {
|
|
3973
|
+
chatBodies = [];
|
|
3974
|
+
resumeBodies = [];
|
|
3975
|
+
});
|
|
3976
|
+
|
|
3977
|
+
// Records chat/resume bodies; `onResume` (keyed by 1-based resume call
|
|
3978
|
+
// index) can override the response for a given resume call.
|
|
3979
|
+
function captureFetch(onResume?: Record<number, () => Response>) {
|
|
3980
|
+
let resumeCall = 0;
|
|
3981
|
+
return vi.fn().mockImplementation(async (url: string, init: { body: string }) => {
|
|
3982
|
+
if (url.includes('/client/chat')) chatBodies.push(JSON.parse(init.body));
|
|
3983
|
+
if (url.includes('/client/resume')) {
|
|
3984
|
+
resumeBodies.push(JSON.parse(init.body));
|
|
3985
|
+
resumeCall += 1;
|
|
3986
|
+
const override = onResume?.[resumeCall];
|
|
3987
|
+
if (override) return override();
|
|
3988
|
+
}
|
|
3989
|
+
return sse();
|
|
3990
|
+
});
|
|
3991
|
+
}
|
|
3992
|
+
|
|
3993
|
+
it('first send of a session ships the full tool list AND a fingerprint on resume', async () => {
|
|
3994
|
+
global.fetch = captureFetch();
|
|
3995
|
+
const client = makeClient(() => TOOLS);
|
|
3996
|
+
|
|
3997
|
+
const response = await client.resumeFlow('exec_1', { call_1: 'ok' });
|
|
3998
|
+
|
|
3999
|
+
expect(response.ok).toBe(true);
|
|
4000
|
+
expect(resumeBodies).toHaveLength(1);
|
|
4001
|
+
expect(resumeBodies[0]!.executionId).toBe('exec_1');
|
|
4002
|
+
expect(resumeBodies[0]!.toolOutputs).toEqual({ call_1: 'ok' });
|
|
4003
|
+
expect(resumeBodies[0]!.clientTools).toEqual(TOOLS);
|
|
4004
|
+
expect(typeof resumeBodies[0]!.clientToolsFingerprint).toBe('string');
|
|
4005
|
+
});
|
|
4006
|
+
|
|
4007
|
+
it('resume after an unchanged chat send is fingerprint-only', async () => {
|
|
4008
|
+
global.fetch = captureFetch();
|
|
4009
|
+
const client = makeClient(() => TOOLS);
|
|
4010
|
+
|
|
4011
|
+
await client.dispatch(userMsg(), () => undefined);
|
|
4012
|
+
await client.resumeFlow('exec_1', { call_1: 'ok' });
|
|
4013
|
+
|
|
4014
|
+
expect(resumeBodies).toHaveLength(1);
|
|
4015
|
+
expect(resumeBodies[0]!.clientTools).toBeUndefined();
|
|
4016
|
+
expect(resumeBodies[0]!.clientToolsFingerprint).toBe(chatBodies[0]!.clientToolsFingerprint);
|
|
4017
|
+
});
|
|
4018
|
+
|
|
4019
|
+
it('a successful resume full-send commits the shared cache: the next chat turn is fingerprint-only', async () => {
|
|
4020
|
+
global.fetch = captureFetch();
|
|
4021
|
+
const client = makeClient(() => TOOLS);
|
|
4022
|
+
|
|
4023
|
+
await client.resumeFlow('exec_1', { call_1: 'ok' });
|
|
4024
|
+
await client.dispatch(userMsg(), () => undefined);
|
|
4025
|
+
|
|
4026
|
+
expect(resumeBodies[0]!.clientTools).toEqual(TOOLS);
|
|
4027
|
+
expect(chatBodies).toHaveLength(1);
|
|
4028
|
+
expect(chatBodies[0]!.clientTools).toBeUndefined();
|
|
4029
|
+
expect(chatBodies[0]!.clientToolsFingerprint).toBe(resumeBodies[0]!.clientToolsFingerprint);
|
|
4030
|
+
});
|
|
4031
|
+
|
|
4032
|
+
it('a changed registry (mid-run navigation) resends the full list on resume', async () => {
|
|
4033
|
+
global.fetch = captureFetch();
|
|
4034
|
+
let live = [...TOOLS];
|
|
4035
|
+
const client = makeClient(() => live);
|
|
4036
|
+
|
|
4037
|
+
await client.dispatch(userMsg(), () => undefined);
|
|
4038
|
+
live = [...TOOLS, { name: 'checkout', description: 'Checkout', origin: 'webmcp' as const }];
|
|
4039
|
+
await client.resumeFlow('exec_1', { call_1: 'ok' });
|
|
4040
|
+
|
|
4041
|
+
expect(resumeBodies[0]!.clientTools).toEqual(live);
|
|
4042
|
+
expect(resumeBodies[0]!.clientToolsFingerprint).not.toBe(chatBodies[0]!.clientToolsFingerprint);
|
|
4043
|
+
});
|
|
4044
|
+
|
|
4045
|
+
it('a 409 client_tools_resend_required on resume retries exactly once with the full list', async () => {
|
|
4046
|
+
global.fetch = captureFetch({
|
|
4047
|
+
1: () =>
|
|
4048
|
+
new Response(JSON.stringify({ error: 'client_tools_resend_required' }), {
|
|
4049
|
+
status: 409,
|
|
4050
|
+
headers: { 'Content-Type': 'application/json' },
|
|
4051
|
+
}),
|
|
4052
|
+
});
|
|
4053
|
+
const client = makeClient(() => TOOLS);
|
|
4054
|
+
|
|
4055
|
+
await client.dispatch(userMsg(), () => undefined); // commits the fingerprint
|
|
4056
|
+
const response = await client.resumeFlow('exec_1', { call_1: 'ok' });
|
|
4057
|
+
|
|
4058
|
+
expect(response.ok).toBe(true);
|
|
4059
|
+
expect(resumeBodies).toHaveLength(2);
|
|
4060
|
+
expect(resumeBodies[0]!.clientTools).toBeUndefined(); // fingerprint-only first
|
|
4061
|
+
expect(resumeBodies[1]!.clientTools).toEqual(TOOLS); // retry carries the full list
|
|
4062
|
+
// ...with the SAME executionId + toolOutputs (no double resume).
|
|
4063
|
+
expect(resumeBodies[1]!.executionId).toBe(resumeBodies[0]!.executionId);
|
|
4064
|
+
expect(resumeBodies[1]!.toolOutputs).toEqual(resumeBodies[0]!.toolOutputs);
|
|
4065
|
+
});
|
|
4066
|
+
|
|
4067
|
+
it('any other 409 is returned once, body still readable (no retry, no consumed stream)', async () => {
|
|
4068
|
+
global.fetch = captureFetch({
|
|
4069
|
+
1: () =>
|
|
4070
|
+
new Response(JSON.stringify({ error: 'execution_conflict' }), {
|
|
4071
|
+
status: 409,
|
|
4072
|
+
headers: { 'Content-Type': 'application/json' },
|
|
4073
|
+
}),
|
|
4074
|
+
});
|
|
4075
|
+
const client = makeClient(() => TOOLS);
|
|
4076
|
+
|
|
4077
|
+
const response = await client.resumeFlow('exec_1', { call_1: 'ok' });
|
|
4078
|
+
|
|
4079
|
+
expect(response.status).toBe(409);
|
|
4080
|
+
expect(resumeBodies).toHaveLength(1);
|
|
4081
|
+
// The 409 probe used a clone, so callers can still parse the error body.
|
|
4082
|
+
await expect(response.json()).resolves.toEqual({ error: 'execution_conflict' });
|
|
4083
|
+
});
|
|
4084
|
+
|
|
4085
|
+
it('a failed resume does not commit the cache: the next chat turn resends full', async () => {
|
|
4086
|
+
global.fetch = captureFetch({
|
|
4087
|
+
1: () => new Response(JSON.stringify({ error: 'boom' }), { status: 500 }),
|
|
4088
|
+
});
|
|
4089
|
+
const client = makeClient(() => TOOLS);
|
|
4090
|
+
|
|
4091
|
+
const response = await client.resumeFlow('exec_1', { call_1: 'ok' });
|
|
4092
|
+
expect(response.status).toBe(500);
|
|
4093
|
+
await client.dispatch(userMsg(), () => undefined);
|
|
4094
|
+
|
|
4095
|
+
expect(chatBodies[0]!.clientTools).toEqual(TOOLS); // full, not fingerprint-only
|
|
4096
|
+
});
|
|
4097
|
+
|
|
4098
|
+
it('registry vanished after a non-empty send: resume ships an explicit clientTools: []', async () => {
|
|
4099
|
+
global.fetch = captureFetch();
|
|
4100
|
+
let live: unknown[] = [...TOOLS];
|
|
4101
|
+
const client = makeClient(() => live);
|
|
4102
|
+
|
|
4103
|
+
await client.dispatch(userMsg(), () => undefined); // non-empty send committed
|
|
4104
|
+
live = []; // navigation landed on a page with no registry
|
|
4105
|
+
await client.resumeFlow('exec_1', { call_1: 'ok' });
|
|
4106
|
+
|
|
4107
|
+
expect(resumeBodies[0]!.clientTools).toEqual([]);
|
|
4108
|
+
expect(resumeBodies[0]!.clientToolsFingerprint).toBeUndefined();
|
|
4109
|
+
});
|
|
4110
|
+
|
|
4111
|
+
it('an interleaved empty-tool chat turn does not lose the pending clear (Greptile P1)', async () => {
|
|
4112
|
+
// Chat with tools (persisted server-side) → chat with an empty registry
|
|
4113
|
+
// (omits both fields, so the paused execution's persisted tools survive)
|
|
4114
|
+
// → resume must STILL send the explicit [] replace.
|
|
4115
|
+
global.fetch = captureFetch();
|
|
4116
|
+
let live: unknown[] = [...TOOLS];
|
|
4117
|
+
const client = makeClient(() => live);
|
|
4118
|
+
|
|
4119
|
+
await client.dispatch(userMsg(), () => undefined); // non-empty send committed
|
|
4120
|
+
live = [];
|
|
4121
|
+
await client.dispatch(userMsg(), () => undefined); // empty chat: omits fields
|
|
4122
|
+
expect(chatBodies[1]!.clientTools).toBeUndefined();
|
|
4123
|
+
await client.resumeFlow('exec_1', { call_1: 'ok' });
|
|
4124
|
+
|
|
4125
|
+
expect(resumeBodies[0]!.clientTools).toEqual([]);
|
|
4126
|
+
expect(resumeBodies[0]!.clientToolsFingerprint).toBeUndefined();
|
|
4127
|
+
|
|
4128
|
+
// The confirmed [] replace clears the flag: a second empty resume omits.
|
|
4129
|
+
await client.resumeFlow('exec_1', { call_2: 'ok' });
|
|
4130
|
+
expect(resumeBodies[1]!.clientTools).toBeUndefined();
|
|
4131
|
+
});
|
|
4132
|
+
|
|
4133
|
+
it('empty registry with no prior send omits both fields', async () => {
|
|
4134
|
+
global.fetch = captureFetch();
|
|
4135
|
+
const client = makeClient(() => []);
|
|
4136
|
+
|
|
4137
|
+
await client.resumeFlow('exec_1', { call_1: 'ok' });
|
|
4138
|
+
|
|
4139
|
+
expect(resumeBodies[0]!.clientTools).toBeUndefined();
|
|
4140
|
+
expect(resumeBodies[0]!.clientToolsFingerprint).toBeUndefined();
|
|
4141
|
+
});
|
|
4142
|
+
|
|
4143
|
+
it('proxy-mode resume never adds clientTools fields (route does not accept them)', async () => {
|
|
4144
|
+
const bodies: Array<Record<string, unknown>> = [];
|
|
4145
|
+
global.fetch = vi.fn().mockImplementation(async (_url: string, init: { body: string }) => {
|
|
4146
|
+
bodies.push(JSON.parse(init.body));
|
|
4147
|
+
return sse();
|
|
4148
|
+
});
|
|
4149
|
+
const client = new AgentWidgetClient({ apiUrl: 'http://localhost:8000/api/chat/dispatch' });
|
|
4150
|
+
(client as unknown as { webMcpBridge: { snapshotForDispatch: () => unknown[] } | null }).webMcpBridge = {
|
|
4151
|
+
snapshotForDispatch: () => TOOLS,
|
|
4152
|
+
};
|
|
4153
|
+
|
|
4154
|
+
await client.resumeFlow('exec_1', { call_1: 'ok' });
|
|
4155
|
+
|
|
4156
|
+
expect(bodies).toHaveLength(1);
|
|
4157
|
+
expect(bodies[0]!.clientTools).toBeUndefined();
|
|
4158
|
+
expect(bodies[0]!.clientToolsFingerprint).toBeUndefined();
|
|
4159
|
+
});
|
|
4160
|
+
});
|
|
4161
|
+
|
|
3848
4162
|
describe('AgentWidgetClient - non-client-token paths always send full clientTools', () => {
|
|
3849
4163
|
function sse(): Response {
|
|
3850
4164
|
const encoder = new TextEncoder();
|