@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/src/client.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
ClientSession,
|
|
18
18
|
ClientInitResponse,
|
|
19
19
|
ClientChatRequest,
|
|
20
|
+
ClientToolDefinition,
|
|
20
21
|
ClientFeedbackRequest,
|
|
21
22
|
ClientFeedbackType,
|
|
22
23
|
PersonaArtifactKind,
|
|
@@ -180,6 +181,14 @@ export class AgentWidgetClient {
|
|
|
180
181
|
// (silent re-init / expiry) forces a fresh full send.
|
|
181
182
|
private lastSentClientToolsFingerprint: string | null = null;
|
|
182
183
|
private clientToolsFingerprintSessionId: string | null = null;
|
|
184
|
+
// Session under which a non-empty clientTools[] was last committed and not
|
|
185
|
+
// yet confirmed cleared server-side. Distinct from the fingerprint above:
|
|
186
|
+
// an empty-tool chat turn commits a null fingerprint, but OMITTING the
|
|
187
|
+
// fields on chat doesn't clear the tools persisted for a still-paused
|
|
188
|
+
// execution — so a later resume with an empty registry must still send the
|
|
189
|
+
// explicit `clientTools: []` replace. Reset only by an explicit [] replace,
|
|
190
|
+
// a session change, or a conversation reset.
|
|
191
|
+
private sentNonEmptyClientToolsSessionId: string | null = null;
|
|
183
192
|
|
|
184
193
|
// WebMCP: page-discovered tool consumption (see ./webmcp-bridge).
|
|
185
194
|
// Constructed lazily: null when `config.webmcp?.enabled !== true`.
|
|
@@ -438,6 +447,7 @@ export class AgentWidgetClient {
|
|
|
438
447
|
public resetClientToolsFingerprint(): void {
|
|
439
448
|
this.lastSentClientToolsFingerprint = null;
|
|
440
449
|
this.clientToolsFingerprintSessionId = null;
|
|
450
|
+
this.sentNonEmptyClientToolsSessionId = null;
|
|
441
451
|
}
|
|
442
452
|
|
|
443
453
|
/**
|
|
@@ -614,6 +624,7 @@ export class AgentWidgetClient {
|
|
|
614
624
|
* Send a message - handles both proxy and client token modes
|
|
615
625
|
*/
|
|
616
626
|
public async dispatch(options: DispatchOptions, onEvent: SSEHandler) {
|
|
627
|
+
options.signal?.throwIfAborted();
|
|
617
628
|
if (this.isClientTokenMode()) {
|
|
618
629
|
return this.dispatchClientToken(options, onEvent);
|
|
619
630
|
}
|
|
@@ -627,11 +638,6 @@ export class AgentWidgetClient {
|
|
|
627
638
|
* Client token mode dispatch
|
|
628
639
|
*/
|
|
629
640
|
private async dispatchClientToken(options: DispatchOptions, onEvent: SSEHandler) {
|
|
630
|
-
const controller = new AbortController();
|
|
631
|
-
if (options.signal) {
|
|
632
|
-
options.signal.addEventListener("abort", () => controller.abort());
|
|
633
|
-
}
|
|
634
|
-
|
|
635
641
|
onEvent({ type: "status", status: "connecting" });
|
|
636
642
|
|
|
637
643
|
try {
|
|
@@ -678,71 +684,32 @@ export class AgentWidgetClient {
|
|
|
678
684
|
};
|
|
679
685
|
|
|
680
686
|
// Diff-only / send-once WebMCP tool dispatch. `buildPayload()` already
|
|
681
|
-
// snapshotted the full set;
|
|
682
|
-
//
|
|
683
|
-
//
|
|
684
|
-
//
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
hasClientTools && sameSession && this.lastSentClientToolsFingerprint === clientToolsFingerprint;
|
|
694
|
-
|
|
695
|
-
// `forceFull` flips to true after a 409 cache-miss so the single retry
|
|
696
|
-
// resends the full list. Capture any error body read inside the loop so
|
|
697
|
-
// the `!response.ok` handler below doesn't re-consume the stream.
|
|
698
|
-
let forceFull = false;
|
|
699
|
-
let errorData: { error?: string; hint?: string } | null = null;
|
|
700
|
-
let response: Response;
|
|
701
|
-
for (let attempt = 0; ; attempt++) {
|
|
702
|
-
const sendFull = hasClientTools && (forceFull || !unchanged);
|
|
703
|
-
const chatRequest: ClientChatRequest = {
|
|
704
|
-
...baseChatRequest,
|
|
705
|
-
...(sendFull && fullClientTools ? { clientTools: fullClientTools } : {}),
|
|
706
|
-
...(clientToolsFingerprint ? { clientToolsFingerprint } : {}),
|
|
707
|
-
};
|
|
708
|
-
|
|
709
|
-
if (this.debug) {
|
|
710
|
-
// eslint-disable-next-line no-console
|
|
711
|
-
console.debug("[AgentWidgetClient] client token dispatch", chatRequest);
|
|
712
|
-
}
|
|
687
|
+
// snapshotted the full set; `sendWithClientToolsDiff` decides whether to
|
|
688
|
+
// ship it again or just its fingerprint (retrying once on a 409 registry
|
|
689
|
+
// miss). The cache is committed only after a successful stream start
|
|
690
|
+
// (below), so a 409/failure leaves it untouched.
|
|
691
|
+
const { response, commit: commitClientToolsFingerprint } =
|
|
692
|
+
await this.sendWithClientToolsDiff(session.sessionId, basePayload.clientTools, (toolFields) => {
|
|
693
|
+
const chatRequest: ClientChatRequest = { ...baseChatRequest, ...toolFields };
|
|
694
|
+
|
|
695
|
+
if (this.debug) {
|
|
696
|
+
// eslint-disable-next-line no-console
|
|
697
|
+
console.debug("[AgentWidgetClient] client token dispatch", chatRequest);
|
|
698
|
+
}
|
|
713
699
|
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
700
|
+
return fetch(this.getClientApiUrl('chat'), {
|
|
701
|
+
method: 'POST',
|
|
702
|
+
headers: {
|
|
703
|
+
'Content-Type': 'application/json',
|
|
704
|
+
'X-Persona-Version': VERSION,
|
|
705
|
+
},
|
|
706
|
+
body: JSON.stringify(chatRequest),
|
|
707
|
+
signal: options.signal,
|
|
708
|
+
});
|
|
722
709
|
});
|
|
723
710
|
|
|
724
|
-
// Diff-only cache miss: the server has no stored tool set matching our
|
|
725
|
-
// fingerprint. Retry exactly once with the full list. A second miss
|
|
726
|
-
// falls through to the normal error handling below (no infinite loop).
|
|
727
|
-
if (response.status === 409 && attempt === 0 && hasClientTools) {
|
|
728
|
-
const body = (await response.json().catch(() => null)) as
|
|
729
|
-
| { error?: string; hint?: string }
|
|
730
|
-
| null;
|
|
731
|
-
if (body?.error === 'client_tools_resend_required') {
|
|
732
|
-
forceFull = true;
|
|
733
|
-
// Invalidate so future turns also resend until a clean success
|
|
734
|
-
// commits a fresh fingerprint.
|
|
735
|
-
this.lastSentClientToolsFingerprint = null;
|
|
736
|
-
continue;
|
|
737
|
-
}
|
|
738
|
-
// Some other 409: keep the parsed body for the handler below.
|
|
739
|
-
errorData = body ?? { error: 'Chat request failed' };
|
|
740
|
-
}
|
|
741
|
-
break;
|
|
742
|
-
}
|
|
743
|
-
|
|
744
711
|
if (!response.ok) {
|
|
745
|
-
const data =
|
|
712
|
+
const data = await response.json().catch(() => ({ error: 'Chat request failed' }));
|
|
746
713
|
|
|
747
714
|
if (response.status === 401) {
|
|
748
715
|
// Session expired
|
|
@@ -773,8 +740,7 @@ export class AgentWidgetClient {
|
|
|
773
740
|
// Stream is good: the server now holds this tool set under this
|
|
774
741
|
// fingerprint for the session. Commit the cache so unchanged follow-up
|
|
775
742
|
// turns can send fingerprint-only.
|
|
776
|
-
|
|
777
|
-
this.clientToolsFingerprintSessionId = session.sessionId;
|
|
743
|
+
commitClientToolsFingerprint();
|
|
778
744
|
|
|
779
745
|
onEvent({ type: "status", status: "connected" });
|
|
780
746
|
|
|
@@ -798,11 +764,6 @@ export class AgentWidgetClient {
|
|
|
798
764
|
* Proxy mode dispatch (original implementation)
|
|
799
765
|
*/
|
|
800
766
|
private async dispatchProxy(options: DispatchOptions, onEvent: SSEHandler) {
|
|
801
|
-
const controller = new AbortController();
|
|
802
|
-
if (options.signal) {
|
|
803
|
-
options.signal.addEventListener("abort", () => controller.abort());
|
|
804
|
-
}
|
|
805
|
-
|
|
806
767
|
onEvent({ type: "status", status: "connecting" });
|
|
807
768
|
|
|
808
769
|
const payload = await this.buildPayload(options.messages);
|
|
@@ -836,7 +797,7 @@ export class AgentWidgetClient {
|
|
|
836
797
|
method: "POST",
|
|
837
798
|
headers,
|
|
838
799
|
body: JSON.stringify(payload),
|
|
839
|
-
signal:
|
|
800
|
+
signal: options.signal
|
|
840
801
|
},
|
|
841
802
|
payload
|
|
842
803
|
);
|
|
@@ -850,7 +811,7 @@ export class AgentWidgetClient {
|
|
|
850
811
|
method: "POST",
|
|
851
812
|
headers,
|
|
852
813
|
body: JSON.stringify(payload),
|
|
853
|
-
signal:
|
|
814
|
+
signal: options.signal
|
|
854
815
|
});
|
|
855
816
|
}
|
|
856
817
|
|
|
@@ -874,11 +835,6 @@ export class AgentWidgetClient {
|
|
|
874
835
|
* Agent mode dispatch
|
|
875
836
|
*/
|
|
876
837
|
private async dispatchAgent(options: DispatchOptions, onEvent: SSEHandler) {
|
|
877
|
-
const controller = new AbortController();
|
|
878
|
-
if (options.signal) {
|
|
879
|
-
options.signal.addEventListener("abort", () => controller.abort());
|
|
880
|
-
}
|
|
881
|
-
|
|
882
838
|
onEvent({ type: "status", status: "connecting" });
|
|
883
839
|
|
|
884
840
|
const payload = await this.buildAgentPayload(options.messages);
|
|
@@ -912,7 +868,7 @@ export class AgentWidgetClient {
|
|
|
912
868
|
method: "POST",
|
|
913
869
|
headers,
|
|
914
870
|
body: JSON.stringify(payload),
|
|
915
|
-
signal:
|
|
871
|
+
signal: options.signal
|
|
916
872
|
},
|
|
917
873
|
payload as unknown as AgentWidgetRequestPayload
|
|
918
874
|
);
|
|
@@ -926,7 +882,7 @@ export class AgentWidgetClient {
|
|
|
926
882
|
method: "POST",
|
|
927
883
|
headers,
|
|
928
884
|
body: JSON.stringify(payload),
|
|
929
|
-
signal:
|
|
885
|
+
signal: options.signal
|
|
930
886
|
});
|
|
931
887
|
}
|
|
932
888
|
|
|
@@ -1007,8 +963,13 @@ export class AgentWidgetClient {
|
|
|
1007
963
|
* - **client-token mode**: POST `${apiBase}/v1/client/resume` (the
|
|
1008
964
|
* session-authenticated sibling of `/v1/client/chat`; runtypelabs/core#3889),
|
|
1009
965
|
* with the active `sessionId` in the body and no Bearer key: a browser
|
|
1010
|
-
* client-token page holds no secret.
|
|
1011
|
-
*
|
|
966
|
+
* client-token page holds no secret. The page's tool registry is
|
|
967
|
+
* re-snapshotted and sent alongside `toolOutputs` via the same diff-only
|
|
968
|
+
* `clientTools` / `clientToolsFingerprint` protocol as `/v1/client/chat`
|
|
969
|
+
* (runtypelabs/core#5361), so tools registered by a mid-run page
|
|
970
|
+
* navigation replace the run's dispatch-time set and become callable on
|
|
971
|
+
* the next model turn. Old servers strip the unknown fields and keep the
|
|
972
|
+
* frozen-at-dispatch behavior.
|
|
1012
973
|
* - **dispatch / proxy mode**: POST `${apiUrl}/resume`: Runtype mounts
|
|
1013
974
|
* resume as a child of `/v1/dispatch`, so the URL is `${apiUrl}/resume`,
|
|
1014
975
|
* and proxies follow the same shape (`/api/chat/dispatch/resume`).
|
|
@@ -1020,6 +981,113 @@ export class AgentWidgetClient {
|
|
|
1020
981
|
* @param toolOutputs - Map keyed by per-call `toolCallId` (core#3878),
|
|
1021
982
|
* falling back to tool name for legacy servers → the tool's result value.
|
|
1022
983
|
*/
|
|
984
|
+
/**
|
|
985
|
+
* Diff-only / send-once WebMCP clientTools transport, shared by the
|
|
986
|
+
* client-token chat (`/v1/client/chat`) and resume (`/v1/client/resume`)
|
|
987
|
+
* paths — both routes speak the same protocol (runtypelabs/core#5361).
|
|
988
|
+
*
|
|
989
|
+
* Decides, against the shared fingerprint cache, whether this request ships
|
|
990
|
+
* the full `clientTools[]` + fingerprint (first send under this session, or
|
|
991
|
+
* a changed set) or the fingerprint alone (unchanged set; the server reuses
|
|
992
|
+
* its stored copy). Runs `doFetch` with the chosen fields and retries
|
|
993
|
+
* EXACTLY once with the full array on a
|
|
994
|
+
* `409 { error: 'client_tools_resend_required' }` registry miss — the retry
|
|
995
|
+
* is 409-*triggered*, never 409-*expected*, so servers predating the
|
|
996
|
+
* protocol (which strip the unknown fields and never 409) work unchanged.
|
|
997
|
+
* The 409 body is probed on a `clone()` so the original response body stays
|
|
998
|
+
* readable by the caller's error handling.
|
|
999
|
+
*
|
|
1000
|
+
* The cache is NOT committed here: callers invoke the returned `commit()`
|
|
1001
|
+
* only after the server has accepted the request (response OK / stream
|
|
1002
|
+
* started), so a failed request can never record a fingerprint the server
|
|
1003
|
+
* never stored. A resend-required miss invalidates the cached fingerprint
|
|
1004
|
+
* immediately so later turns keep resending in full until a clean success
|
|
1005
|
+
* commits a fresh one.
|
|
1006
|
+
*
|
|
1007
|
+
* `emptyMeansReplace` (resume only): when the live snapshot is empty but a
|
|
1008
|
+
* non-empty set was committed under this session and never explicitly
|
|
1009
|
+
* cleared (the paused tool navigated to a page with no tool registry), ship
|
|
1010
|
+
* an explicit `clientTools: []` so the server REPLACES the persisted
|
|
1011
|
+
* dispatch-time set with nothing and clears its stored registry. Chat keeps
|
|
1012
|
+
* its omit-when-empty behavior: on `/chat`, absent fields already mean "no
|
|
1013
|
+
* tools this turn", whereas on `/resume` absence means "keep the frozen
|
|
1014
|
+
* dispatch-time set".
|
|
1015
|
+
*/
|
|
1016
|
+
private async sendWithClientToolsDiff(
|
|
1017
|
+
sessionId: string,
|
|
1018
|
+
fullClientTools: ClientToolDefinition[] | undefined,
|
|
1019
|
+
doFetch: (
|
|
1020
|
+
toolFields: Pick<ClientChatRequest, 'clientTools' | 'clientToolsFingerprint'>
|
|
1021
|
+
) => Promise<Response>,
|
|
1022
|
+
opts?: { emptyMeansReplace?: boolean }
|
|
1023
|
+
): Promise<{ response: Response; commit: () => void }> {
|
|
1024
|
+
const hasClientTools = !!(fullClientTools && fullClientTools.length > 0);
|
|
1025
|
+
const clientToolsFingerprint = hasClientTools
|
|
1026
|
+
? computeClientToolsFingerprint(fullClientTools!)
|
|
1027
|
+
: undefined;
|
|
1028
|
+
const sameSession = this.clientToolsFingerprintSessionId === sessionId;
|
|
1029
|
+
const unchanged =
|
|
1030
|
+
hasClientTools && sameSession && this.lastSentClientToolsFingerprint === clientToolsFingerprint;
|
|
1031
|
+
// Keyed on `sentNonEmptyClientToolsSessionId`, NOT the fingerprint: an
|
|
1032
|
+
// interleaved empty-tool chat turn commits a null fingerprint without
|
|
1033
|
+
// clearing the tools persisted for a still-paused execution, so the
|
|
1034
|
+
// fingerprint alone would lose the pending clear. The dedicated flag
|
|
1035
|
+
// survives omitted-empty commits and is reset only once an explicit []
|
|
1036
|
+
// replace is confirmed.
|
|
1037
|
+
const sendEmptyReplace =
|
|
1038
|
+
!hasClientTools &&
|
|
1039
|
+
opts?.emptyMeansReplace === true &&
|
|
1040
|
+
this.sentNonEmptyClientToolsSessionId === sessionId;
|
|
1041
|
+
|
|
1042
|
+
// `forceFull` flips to true after a 409 cache-miss so the single retry
|
|
1043
|
+
// resends the full list.
|
|
1044
|
+
let forceFull = false;
|
|
1045
|
+
let response: Response;
|
|
1046
|
+
for (let attempt = 0; ; attempt++) {
|
|
1047
|
+
const sendFull = hasClientTools && (forceFull || !unchanged);
|
|
1048
|
+
response = await doFetch({
|
|
1049
|
+
...(sendFull && fullClientTools ? { clientTools: fullClientTools } : {}),
|
|
1050
|
+
...(sendEmptyReplace ? { clientTools: [] } : {}),
|
|
1051
|
+
...(clientToolsFingerprint ? { clientToolsFingerprint } : {}),
|
|
1052
|
+
});
|
|
1053
|
+
|
|
1054
|
+
// Diff-only cache miss: the server has no stored tool set matching our
|
|
1055
|
+
// fingerprint. Retry exactly once with the full list. A second miss
|
|
1056
|
+
// falls through to the caller's normal error handling (no infinite loop).
|
|
1057
|
+
if (response.status === 409 && attempt === 0 && hasClientTools) {
|
|
1058
|
+
const body = (await response
|
|
1059
|
+
.clone()
|
|
1060
|
+
.json()
|
|
1061
|
+
.catch(() => null)) as { error?: string } | null;
|
|
1062
|
+
if (body?.error === 'client_tools_resend_required') {
|
|
1063
|
+
forceFull = true;
|
|
1064
|
+
// Invalidate so future turns also resend until a clean success
|
|
1065
|
+
// commits a fresh fingerprint.
|
|
1066
|
+
this.lastSentClientToolsFingerprint = null;
|
|
1067
|
+
continue;
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
break;
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
return {
|
|
1074
|
+
response,
|
|
1075
|
+
commit: () => {
|
|
1076
|
+
this.lastSentClientToolsFingerprint = clientToolsFingerprint ?? null;
|
|
1077
|
+
this.clientToolsFingerprintSessionId = sessionId;
|
|
1078
|
+
if (hasClientTools) {
|
|
1079
|
+
this.sentNonEmptyClientToolsSessionId = sessionId;
|
|
1080
|
+
} else if (sendEmptyReplace) {
|
|
1081
|
+
// The explicit [] replaced the persisted set server-side; the
|
|
1082
|
+
// pending clear is done.
|
|
1083
|
+
this.sentNonEmptyClientToolsSessionId = null;
|
|
1084
|
+
}
|
|
1085
|
+
// Omitted-empty commits (chat with zero tools) leave the flag set:
|
|
1086
|
+
// they don't touch tools persisted for a paused execution.
|
|
1087
|
+
},
|
|
1088
|
+
};
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1023
1091
|
public async resumeFlow(
|
|
1024
1092
|
executionId: string,
|
|
1025
1093
|
toolOutputs: Record<string, unknown>,
|
|
@@ -1060,6 +1128,47 @@ export class AgentWidgetClient {
|
|
|
1060
1128
|
body.sessionId = resumeSessionId;
|
|
1061
1129
|
}
|
|
1062
1130
|
|
|
1131
|
+
if (isClientToken && resumeSessionId) {
|
|
1132
|
+
// Mid-run WebMCP tool refresh (runtypelabs/core#5361): the paused tool
|
|
1133
|
+
// may have navigated the page, so the dispatch-time snapshot the server
|
|
1134
|
+
// persisted can be stale. Re-snapshot the registry — the same built-in +
|
|
1135
|
+
// bridge composition as the payload builders, so fingerprints computed
|
|
1136
|
+
// here and on chat turns describe the same tool space — and ship it via
|
|
1137
|
+
// the shared diff-only protocol. `emptyMeansReplace` sends an explicit
|
|
1138
|
+
// `clientTools: []` when the registry vanished after a non-empty send,
|
|
1139
|
+
// so the server replaces the persisted set instead of keeping it frozen.
|
|
1140
|
+
const fullClientTools = [
|
|
1141
|
+
...builtInClientToolsForDispatch(this.config),
|
|
1142
|
+
...((await this.webMcpBridge?.snapshotForDispatch()) ?? []),
|
|
1143
|
+
];
|
|
1144
|
+
const { response, commit } = await this.sendWithClientToolsDiff(
|
|
1145
|
+
resumeSessionId,
|
|
1146
|
+
fullClientTools,
|
|
1147
|
+
(toolFields) => {
|
|
1148
|
+
const resumeRequest = { ...body, ...toolFields };
|
|
1149
|
+
if (this.debug) {
|
|
1150
|
+
// eslint-disable-next-line no-console
|
|
1151
|
+
console.debug("[AgentWidgetClient] client token resume", resumeRequest);
|
|
1152
|
+
}
|
|
1153
|
+
return fetch(url, {
|
|
1154
|
+
method: 'POST',
|
|
1155
|
+
headers,
|
|
1156
|
+
body: JSON.stringify(resumeRequest),
|
|
1157
|
+
signal: options?.signal,
|
|
1158
|
+
});
|
|
1159
|
+
},
|
|
1160
|
+
{ emptyMeansReplace: true }
|
|
1161
|
+
);
|
|
1162
|
+
// The server stores the refreshed registry before running the
|
|
1163
|
+
// continuation pipeline, so an OK response means it holds this set under
|
|
1164
|
+
// this fingerprint. Mirror chat's commit-on-success discipline: a failed
|
|
1165
|
+
// resume must not record a fingerprint the server never stored.
|
|
1166
|
+
if (response.ok) {
|
|
1167
|
+
commit();
|
|
1168
|
+
}
|
|
1169
|
+
return response;
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1063
1172
|
return fetch(url, {
|
|
1064
1173
|
method: 'POST',
|
|
1065
1174
|
headers,
|
|
@@ -2562,7 +2671,11 @@ export class AgentWidgetClient {
|
|
|
2562
2671
|
const message =
|
|
2563
2672
|
typeof e === "string" && e !== ""
|
|
2564
2673
|
? e
|
|
2565
|
-
:
|
|
2674
|
+
: // Reflect.has, not `in`: the `in` operator inside an arrow body can
|
|
2675
|
+
// be minified into a `for(init;;)` head, which Oxc mis-parses and
|
|
2676
|
+
// Rolldown (Vite 8) silently emits as an empty chunk. Same
|
|
2677
|
+
// [[HasProperty]] semantics. Enforced by scripts/check-dist-no-in-for-init.mjs.
|
|
2678
|
+
e != null && typeof e === "object" && Reflect.has(e, "message")
|
|
2566
2679
|
? String((e as { message?: unknown }).message ?? "Step failed")
|
|
2567
2680
|
: "Step failed";
|
|
2568
2681
|
onEvent({ type: "error", error: new Error(message) });
|
|
@@ -3145,7 +3258,8 @@ export class AgentWidgetClient {
|
|
|
3145
3258
|
const e = payload.error;
|
|
3146
3259
|
if (typeof e === "string" && e !== "") {
|
|
3147
3260
|
resolvedError = new Error(e);
|
|
3148
|
-
} else if (e != null && typeof e === "object" && "message"
|
|
3261
|
+
} else if (e != null && typeof e === "object" && Reflect.has(e, "message")) {
|
|
3262
|
+
// Reflect.has, not `in` — see the note on the equivalent guard above.
|
|
3149
3263
|
resolvedError = new Error(String((e as { message?: unknown }).message ?? e));
|
|
3150
3264
|
}
|
|
3151
3265
|
}
|
|
@@ -41,6 +41,7 @@ export type RuntypeExecutionStreamEvent = ({
|
|
|
41
41
|
};
|
|
42
42
|
type: "execution_complete";
|
|
43
43
|
}) | ({
|
|
44
|
+
blockReason?: string;
|
|
44
45
|
code?: string;
|
|
45
46
|
completedAt?: string;
|
|
46
47
|
error: string | {
|
|
@@ -295,6 +296,7 @@ export type RuntypeExecutionStreamEvent = ({
|
|
|
295
296
|
startedAt?: string;
|
|
296
297
|
subagent?: {
|
|
297
298
|
agentName?: string;
|
|
299
|
+
parentToolCallId?: string;
|
|
298
300
|
toolName: string;
|
|
299
301
|
};
|
|
300
302
|
timeout?: number;
|
|
@@ -389,6 +391,7 @@ export type RuntypeFlowSSEEvent = {
|
|
|
389
391
|
totalTokensUsed?: number;
|
|
390
392
|
type: "flow_complete";
|
|
391
393
|
} | ({
|
|
394
|
+
blockReason?: string;
|
|
392
395
|
code?: string;
|
|
393
396
|
error: string | {
|
|
394
397
|
code: string;
|
|
@@ -409,6 +412,7 @@ export type RuntypeFlowSSEEvent = {
|
|
|
409
412
|
type: "flow_error";
|
|
410
413
|
upgradeUrl?: string;
|
|
411
414
|
}) | ({
|
|
415
|
+
approvalId?: string;
|
|
412
416
|
awaitReason?: string;
|
|
413
417
|
awaitedAt: string;
|
|
414
418
|
crawlId?: string;
|
|
@@ -419,6 +423,7 @@ export type RuntypeFlowSSEEvent = {
|
|
|
419
423
|
parameters?: Record<string, unknown>;
|
|
420
424
|
seq?: number;
|
|
421
425
|
stepId?: string;
|
|
426
|
+
timeout?: number;
|
|
422
427
|
toolCallId?: string;
|
|
423
428
|
toolId?: string;
|
|
424
429
|
toolName?: string;
|
|
@@ -673,7 +678,7 @@ export type RuntypeClientInitResponse = {
|
|
|
673
678
|
welcomeMessage?: string | null;
|
|
674
679
|
};
|
|
675
680
|
expiresAt: string;
|
|
676
|
-
flow
|
|
681
|
+
flow?: {
|
|
677
682
|
description?: string | null;
|
|
678
683
|
id: string;
|
|
679
684
|
name?: string | null;
|
|
@@ -695,6 +700,10 @@ export type RuntypeClientChatRequest = {
|
|
|
695
700
|
untrustedContentHint?: boolean;
|
|
696
701
|
}>;
|
|
697
702
|
clientToolsFingerprint?: string;
|
|
703
|
+
identityProof?: {
|
|
704
|
+
provider: string;
|
|
705
|
+
token: string;
|
|
706
|
+
};
|
|
698
707
|
inputs?: Record<string, unknown>;
|
|
699
708
|
messages: Array<{
|
|
700
709
|
content: string | (Array<{
|
|
@@ -16,6 +16,23 @@ const baseConfig = () => ({
|
|
|
16
16
|
const inject = (controller: ReturnType<typeof createAgentExperience>) =>
|
|
17
17
|
controller.injectAssistantMessage({ content: "hello world" });
|
|
18
18
|
|
|
19
|
+
const deferred = () => {
|
|
20
|
+
let resolve!: () => void;
|
|
21
|
+
let reject!: (error: unknown) => void;
|
|
22
|
+
const promise = new Promise<void>((resolvePromise, rejectPromise) => {
|
|
23
|
+
resolve = resolvePromise;
|
|
24
|
+
reject = rejectPromise;
|
|
25
|
+
});
|
|
26
|
+
return { promise, resolve, reject };
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const flushMicrotasks = async (times = 4) => {
|
|
30
|
+
for (let index = 0; index < times; index += 1) {
|
|
31
|
+
// eslint-disable-next-line no-await-in-loop
|
|
32
|
+
await Promise.resolve();
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
19
36
|
describe("persistState gates storage adapter", () => {
|
|
20
37
|
afterEach(() => {
|
|
21
38
|
document.body.innerHTML = "";
|
|
@@ -149,4 +166,105 @@ describe("persistState gates storage adapter", () => {
|
|
|
149
166
|
expect(controller.getMessages()).toEqual([]);
|
|
150
167
|
controller.destroy();
|
|
151
168
|
});
|
|
169
|
+
|
|
170
|
+
it("serializes async saves and preserves the newest transcript", async () => {
|
|
171
|
+
const mount = document.createElement("div");
|
|
172
|
+
document.body.appendChild(mount);
|
|
173
|
+
const firstSave = deferred();
|
|
174
|
+
const payloads: Array<{ messages?: Array<{ content?: string }> }> = [];
|
|
175
|
+
const save = vi.fn((state: { messages?: Array<{ content?: string }> }) => {
|
|
176
|
+
payloads.push(state);
|
|
177
|
+
return save.mock.calls.length === 1 ? firstSave.promise : Promise.resolve();
|
|
178
|
+
});
|
|
179
|
+
const controller = createAgentExperience(mount, {
|
|
180
|
+
...baseConfig(),
|
|
181
|
+
storageAdapter: { save },
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
controller.injectAssistantMessage({ content: "first" });
|
|
185
|
+
controller.injectAssistantMessage({ content: "second" });
|
|
186
|
+
|
|
187
|
+
expect(save).toHaveBeenCalledTimes(1);
|
|
188
|
+
firstSave.resolve();
|
|
189
|
+
await flushMicrotasks();
|
|
190
|
+
|
|
191
|
+
expect(save).toHaveBeenCalledTimes(2);
|
|
192
|
+
expect(payloads[1].messages?.map((message) => message.content)).toEqual([
|
|
193
|
+
"first",
|
|
194
|
+
"second",
|
|
195
|
+
]);
|
|
196
|
+
controller.destroy();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("orders clear after pending saves so stale state cannot be resurrected", async () => {
|
|
200
|
+
const mount = document.createElement("div");
|
|
201
|
+
document.body.appendChild(mount);
|
|
202
|
+
const firstSave = deferred();
|
|
203
|
+
const operations: string[] = [];
|
|
204
|
+
const save = vi.fn(() => {
|
|
205
|
+
operations.push("save");
|
|
206
|
+
return save.mock.calls.length === 1 ? firstSave.promise : Promise.resolve();
|
|
207
|
+
});
|
|
208
|
+
const clear = vi.fn(() => {
|
|
209
|
+
operations.push("clear");
|
|
210
|
+
return Promise.resolve();
|
|
211
|
+
});
|
|
212
|
+
const controller = createAgentExperience(mount, {
|
|
213
|
+
...baseConfig(),
|
|
214
|
+
storageAdapter: { save, clear },
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
inject(controller);
|
|
218
|
+
controller.clearChat();
|
|
219
|
+
|
|
220
|
+
expect(operations).toEqual(["save"]);
|
|
221
|
+
firstSave.resolve();
|
|
222
|
+
await flushMicrotasks(20);
|
|
223
|
+
|
|
224
|
+
expect(operations).toEqual(["save", "save", "clear"]);
|
|
225
|
+
expect(clear).toHaveBeenCalledTimes(1);
|
|
226
|
+
controller.destroy();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("continues queued storage mutations after an async save rejects", async () => {
|
|
230
|
+
const mount = document.createElement("div");
|
|
231
|
+
document.body.appendChild(mount);
|
|
232
|
+
const firstSave = deferred();
|
|
233
|
+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
234
|
+
const save = vi.fn(() =>
|
|
235
|
+
save.mock.calls.length === 1 ? firstSave.promise : Promise.resolve()
|
|
236
|
+
);
|
|
237
|
+
const controller = createAgentExperience(mount, {
|
|
238
|
+
...baseConfig(),
|
|
239
|
+
storageAdapter: { save },
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
controller.injectAssistantMessage({ content: "first" });
|
|
243
|
+
controller.injectAssistantMessage({ content: "second" });
|
|
244
|
+
firstSave.reject(new Error("storage unavailable"));
|
|
245
|
+
await flushMicrotasks(8);
|
|
246
|
+
|
|
247
|
+
expect(save).toHaveBeenCalledTimes(2);
|
|
248
|
+
expect(errorSpy).toHaveBeenCalledWith(
|
|
249
|
+
"[AgentWidget] Failed to persist state:",
|
|
250
|
+
expect.any(Error)
|
|
251
|
+
);
|
|
252
|
+
errorSpy.mockRestore();
|
|
253
|
+
controller.destroy();
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("keeps synchronous adapters synchronous", () => {
|
|
257
|
+
const mount = document.createElement("div");
|
|
258
|
+
document.body.appendChild(mount);
|
|
259
|
+
const save = vi.fn();
|
|
260
|
+
const controller = createAgentExperience(mount, {
|
|
261
|
+
...baseConfig(),
|
|
262
|
+
storageAdapter: { save },
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
inject(controller);
|
|
266
|
+
|
|
267
|
+
expect(save).toHaveBeenCalledTimes(1);
|
|
268
|
+
controller.destroy();
|
|
269
|
+
});
|
|
152
270
|
});
|
|
@@ -30,7 +30,7 @@ const getLiveRegion = (mount: HTMLElement) =>
|
|
|
30
30
|
const installRafMock = () => {
|
|
31
31
|
let nextId = 1;
|
|
32
32
|
const callbacks = new Map<number, RafCallback>();
|
|
33
|
-
let now =
|
|
33
|
+
let now = performance.now();
|
|
34
34
|
vi.stubGlobal("requestAnimationFrame", (cb: RafCallback) => {
|
|
35
35
|
const id = nextId++;
|
|
36
36
|
callbacks.set(id, cb);
|