@runtypelabs/persona 4.23.0 → 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 +43 -43
- 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 +28 -28
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +43 -43
- 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 +49 -49
- package/dist/theme-editor-preview.d.cts +46 -3
- package/dist/theme-editor-preview.d.ts +46 -3
- package/dist/theme-editor-preview.js +33 -33
- package/dist/theme-editor.d.cts +46 -3
- package/dist/theme-editor.d.ts +46 -3
- package/package.json +1 -1
- package/src/client-session-renewal.test.ts +268 -0
- package/src/client.interrupt.test.ts +1 -1
- package/src/client.tool-pair-replay.test.ts +325 -0
- package/src/client.ts +93 -49
- 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,268 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { AgentWidgetClient } from "./client";
|
|
3
|
+
import type { AgentWidgetEvent, ClientSession } from "./types";
|
|
4
|
+
import { createVisitorStore } from "./utils/visitor-store";
|
|
5
|
+
|
|
6
|
+
const message = {
|
|
7
|
+
id: "user-1",
|
|
8
|
+
role: "user" as const,
|
|
9
|
+
content: "continue",
|
|
10
|
+
createdAt: "2026-09-22T00:00:00Z",
|
|
11
|
+
};
|
|
12
|
+
const options = { messages: [message], assistantMessageId: "assistant-1" };
|
|
13
|
+
const session = (milliseconds = 600_000): ClientSession => ({
|
|
14
|
+
sessionId: "session-old",
|
|
15
|
+
conversationId: "conversation-owned",
|
|
16
|
+
expiresAt: new Date(Date.now() + milliseconds),
|
|
17
|
+
durableRecovery: { enabled: true },
|
|
18
|
+
flow: { id: "agent-1", name: "Agent", description: null },
|
|
19
|
+
config: { welcomeMessage: null, placeholder: "Ask...", theme: null },
|
|
20
|
+
});
|
|
21
|
+
const expired = () =>
|
|
22
|
+
Response.json({ error: "Session not found or expired" }, { status: 401 });
|
|
23
|
+
const success = () =>
|
|
24
|
+
new Response('data: {"type":"done"}\n\n', {
|
|
25
|
+
headers: { "Content-Type": "text/event-stream" },
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
async function setup(milliseconds = 600_000) {
|
|
29
|
+
const onSessionInit = vi.fn();
|
|
30
|
+
const onSessionExpired = vi.fn();
|
|
31
|
+
const setStoredSessionId = vi.fn();
|
|
32
|
+
const store = createVisitorStore("ct_test_renewal", "renewal-", true);
|
|
33
|
+
await store.set("cvt_owned");
|
|
34
|
+
const client = new AgentWidgetClient({
|
|
35
|
+
clientToken: "ct_test_renewal",
|
|
36
|
+
apiUrl: "https://example.com",
|
|
37
|
+
onSessionInit,
|
|
38
|
+
onSessionExpired,
|
|
39
|
+
setStoredSessionId,
|
|
40
|
+
});
|
|
41
|
+
client.setHistoryInternals({ visitorStore: store });
|
|
42
|
+
(client as unknown as { clientSession: ClientSession }).clientSession =
|
|
43
|
+
session(milliseconds);
|
|
44
|
+
const requests: Array<{
|
|
45
|
+
url: string;
|
|
46
|
+
body: Record<string, unknown>;
|
|
47
|
+
headers: Headers;
|
|
48
|
+
}> = [];
|
|
49
|
+
const chat = vi
|
|
50
|
+
.fn<() => Response | Promise<Response>>()
|
|
51
|
+
.mockImplementation(success);
|
|
52
|
+
const initialize = vi
|
|
53
|
+
.fn<() => Response | Promise<Response>>()
|
|
54
|
+
.mockImplementation(() =>
|
|
55
|
+
Response.json({ ...session(), sessionId: "session-new" }),
|
|
56
|
+
);
|
|
57
|
+
vi.stubGlobal(
|
|
58
|
+
"fetch",
|
|
59
|
+
vi.fn(async (url: string, init: RequestInit) => {
|
|
60
|
+
requests.push({
|
|
61
|
+
url,
|
|
62
|
+
body: JSON.parse(String(init.body)),
|
|
63
|
+
headers: new Headers(init.headers),
|
|
64
|
+
});
|
|
65
|
+
return url.endsWith("/init") ? initialize() : chat();
|
|
66
|
+
}),
|
|
67
|
+
);
|
|
68
|
+
const events: AgentWidgetEvent[] = [];
|
|
69
|
+
const run = (signal?: AbortSignal) =>
|
|
70
|
+
client.dispatch({ ...options, signal }, (event) => events.push(event));
|
|
71
|
+
return {
|
|
72
|
+
client,
|
|
73
|
+
store,
|
|
74
|
+
requests,
|
|
75
|
+
chat,
|
|
76
|
+
initialize,
|
|
77
|
+
onSessionInit,
|
|
78
|
+
onSessionExpired,
|
|
79
|
+
setStoredSessionId,
|
|
80
|
+
events,
|
|
81
|
+
run,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
afterEach(() => {
|
|
86
|
+
vi.restoreAllMocks();
|
|
87
|
+
vi.unstubAllGlobals();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("client token session renewal", () => {
|
|
91
|
+
it("renews a legacy session through its session ID without enabling shared history", async () => {
|
|
92
|
+
const h = await setup(30_000);
|
|
93
|
+
(h.client as unknown as { clientSession: ClientSession }).clientSession = {
|
|
94
|
+
...session(30_000),
|
|
95
|
+
durableRecovery: { enabled: false },
|
|
96
|
+
};
|
|
97
|
+
h.initialize.mockImplementation(() =>
|
|
98
|
+
Response.json({
|
|
99
|
+
...session(),
|
|
100
|
+
sessionId: "session-new",
|
|
101
|
+
durableRecovery: { enabled: false },
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
104
|
+
await h.run();
|
|
105
|
+
expect(h.requests[0].body).toMatchObject({ sessionId: "session-old" });
|
|
106
|
+
expect(h.requests[0].body).not.toHaveProperty("conversationId");
|
|
107
|
+
expect(h.requests[0].body).not.toHaveProperty("visitorHistory");
|
|
108
|
+
expect(h.requests[1].body.sessionId).toBe("session-new");
|
|
109
|
+
expect(h.onSessionExpired).not.toHaveBeenCalled();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("resends the full tool registry after renewal rather than reusing the old fingerprint alone", async () => {
|
|
113
|
+
const h = await setup();
|
|
114
|
+
const tools = [{ name: "search", description: "Search", origin: "webmcp" }];
|
|
115
|
+
(h.client as unknown as { webMcpBridge: unknown }).webMcpBridge = {
|
|
116
|
+
snapshotForDispatch: () => tools,
|
|
117
|
+
};
|
|
118
|
+
await h.run();
|
|
119
|
+
h.chat.mockImplementationOnce(expired);
|
|
120
|
+
await h.run();
|
|
121
|
+
const bodies = h.requests
|
|
122
|
+
.filter((r) => r.url.endsWith("/chat"))
|
|
123
|
+
.map((r) => r.body);
|
|
124
|
+
expect(bodies[0].clientTools).toEqual(tools);
|
|
125
|
+
expect(bodies[1].clientTools).toBeUndefined();
|
|
126
|
+
expect(bodies[2].clientTools).toEqual(tools);
|
|
127
|
+
expect(bodies[2].clientToolsFingerprint).toBe(
|
|
128
|
+
bodies[1].clientToolsFingerprint,
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it.each([30_000, -1])(
|
|
133
|
+
"renews a session with %i ms left and preserves visitor-owned conversation without history UI",
|
|
134
|
+
async (milliseconds) => {
|
|
135
|
+
const h = await setup(milliseconds);
|
|
136
|
+
await h.run();
|
|
137
|
+
expect(h.requests.map((r) => r.url)).toEqual([
|
|
138
|
+
"https://example.com/v1/client/init",
|
|
139
|
+
"https://example.com/v1/client/chat",
|
|
140
|
+
]);
|
|
141
|
+
expect(h.requests[0].body).toMatchObject({
|
|
142
|
+
conversationId: "conversation-owned",
|
|
143
|
+
visitorToken: "cvt_owned",
|
|
144
|
+
durableRecovery: true,
|
|
145
|
+
});
|
|
146
|
+
expect(h.requests[0].body).not.toHaveProperty("sessionId");
|
|
147
|
+
expect(h.requests[1].body).toMatchObject({
|
|
148
|
+
sessionId: "session-new",
|
|
149
|
+
assistantMessageId: "assistant-1",
|
|
150
|
+
messages: [{ id: "user-1", content: "continue" }],
|
|
151
|
+
});
|
|
152
|
+
expect(h.requests[1].headers.get("X-Visitor-Token")).toBe("cvt_owned");
|
|
153
|
+
expect(h.onSessionInit).toHaveBeenCalledTimes(1);
|
|
154
|
+
expect(h.onSessionExpired).not.toHaveBeenCalled();
|
|
155
|
+
expect(h.setStoredSessionId).toHaveBeenCalledWith("session-new");
|
|
156
|
+
},
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
it("retries only an explicit expired-session rejection with the same payload and turn identity", async () => {
|
|
160
|
+
const h = await setup();
|
|
161
|
+
h.chat.mockImplementationOnce(expired);
|
|
162
|
+
await h.run();
|
|
163
|
+
const bodies = h.requests
|
|
164
|
+
.filter((r) => r.url.endsWith("/chat"))
|
|
165
|
+
.map((r) => r.body);
|
|
166
|
+
expect(bodies).toHaveLength(2);
|
|
167
|
+
expect(bodies[1]).toEqual({ ...bodies[0], sessionId: "session-new" });
|
|
168
|
+
expect(h.onSessionExpired).not.toHaveBeenCalled();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("stops after a second expired-session rejection", async () => {
|
|
172
|
+
const h = await setup();
|
|
173
|
+
h.chat.mockImplementation(expired);
|
|
174
|
+
await expect(h.run()).rejects.toThrow("Session expired");
|
|
175
|
+
expect(h.chat).toHaveBeenCalledTimes(2);
|
|
176
|
+
expect(h.initialize).toHaveBeenCalledTimes(1);
|
|
177
|
+
expect(h.onSessionExpired).toHaveBeenCalledTimes(1);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("does not retry an unrelated unauthorized response", async () => {
|
|
181
|
+
const h = await setup();
|
|
182
|
+
h.chat.mockImplementation(() =>
|
|
183
|
+
Response.json({ error: "Unauthorized" }, { status: 401 }),
|
|
184
|
+
);
|
|
185
|
+
await expect(h.run()).rejects.toThrow("Session expired");
|
|
186
|
+
expect(h.chat).toHaveBeenCalledTimes(1);
|
|
187
|
+
expect(h.initialize).not.toHaveBeenCalled();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("does not renew or replay after cancellation during the rejected request", async () => {
|
|
191
|
+
const h = await setup();
|
|
192
|
+
const abort = new AbortController();
|
|
193
|
+
h.chat.mockImplementation(() => {
|
|
194
|
+
abort.abort();
|
|
195
|
+
return expired();
|
|
196
|
+
});
|
|
197
|
+
await expect(h.run(abort.signal)).rejects.toMatchObject({
|
|
198
|
+
name: "AbortError",
|
|
199
|
+
});
|
|
200
|
+
expect(h.initialize).not.toHaveBeenCalled();
|
|
201
|
+
expect(h.chat).toHaveBeenCalledTimes(1);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("does not install a renewal or post a message after cancellation during init", async () => {
|
|
205
|
+
const h = await setup(30_000);
|
|
206
|
+
const abort = new AbortController();
|
|
207
|
+
h.initialize.mockImplementation(() => {
|
|
208
|
+
abort.abort();
|
|
209
|
+
return Response.json({ ...session(), sessionId: "session-new" });
|
|
210
|
+
});
|
|
211
|
+
await expect(h.run(abort.signal)).rejects.toMatchObject({
|
|
212
|
+
name: "AbortError",
|
|
213
|
+
});
|
|
214
|
+
expect(h.chat).not.toHaveBeenCalled();
|
|
215
|
+
expect(h.onSessionInit).not.toHaveBeenCalled();
|
|
216
|
+
expect(h.setStoredSessionId).not.toHaveBeenCalled();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("does not fork a conversation when renewal cannot reopen it", async () => {
|
|
220
|
+
const h = await setup(30_000);
|
|
221
|
+
h.initialize.mockImplementation(() =>
|
|
222
|
+
Response.json({ error: "not_found" }, { status: 404 }),
|
|
223
|
+
);
|
|
224
|
+
await expect(h.run()).rejects.toThrow("Conversation not found");
|
|
225
|
+
expect(h.initialize).toHaveBeenCalledTimes(1);
|
|
226
|
+
expect(h.chat).not.toHaveBeenCalled();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("rejects a server response that switches conversations", async () => {
|
|
230
|
+
const h = await setup(30_000);
|
|
231
|
+
h.initialize.mockImplementation(() =>
|
|
232
|
+
Response.json({ ...session(), conversationId: "different" }),
|
|
233
|
+
);
|
|
234
|
+
await expect(h.run()).rejects.toThrow("did not preserve");
|
|
235
|
+
expect(h.chat).not.toHaveBeenCalled();
|
|
236
|
+
expect(h.onSessionInit).not.toHaveBeenCalled();
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("does not renew a durable conversation without visitor proof", async () => {
|
|
240
|
+
const h = await setup(30_000);
|
|
241
|
+
await h.store.clear();
|
|
242
|
+
await expect(h.run()).rejects.toThrow("visitor credential");
|
|
243
|
+
expect(h.initialize).not.toHaveBeenCalled();
|
|
244
|
+
expect(h.chat).not.toHaveBeenCalled();
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("suppresses a superseded turn while its expired-session response is pending", async () => {
|
|
248
|
+
const h = await setup();
|
|
249
|
+
let rejectOld!: (response: Response) => void;
|
|
250
|
+
let started!: () => void;
|
|
251
|
+
const firstStarted = new Promise<void>((resolve) => {
|
|
252
|
+
started = resolve;
|
|
253
|
+
});
|
|
254
|
+
h.chat.mockImplementationOnce(() => {
|
|
255
|
+
started();
|
|
256
|
+
return new Promise((resolve) => {
|
|
257
|
+
rejectOld = resolve;
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
const first = h.run();
|
|
261
|
+
await firstStarted;
|
|
262
|
+
await h.run();
|
|
263
|
+
rejectOld(expired());
|
|
264
|
+
await expect(first).rejects.toMatchObject({ name: "AbortError" });
|
|
265
|
+
expect(h.initialize).not.toHaveBeenCalled();
|
|
266
|
+
expect(h.chat).toHaveBeenCalledTimes(2);
|
|
267
|
+
});
|
|
268
|
+
});
|
|
@@ -111,7 +111,7 @@ describe("client-token turn identity", () => {
|
|
|
111
111
|
);
|
|
112
112
|
// Let the first dispatch claim the turn and start streaming.
|
|
113
113
|
await vi.waitFor(() =>
|
|
114
|
-
expect(firstEvents.some((e) => e.type === "status")).toBe(true)
|
|
114
|
+
expect(firstEvents.some((e) => e.type === "status" && e.status === "connected")).toBe(true)
|
|
115
115
|
);
|
|
116
116
|
|
|
117
117
|
const secondDispatch = client.dispatch({ messages, interrupt: true }, (event) =>
|