@runtypelabs/persona 3.21.0 → 3.21.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/README.md +7 -1
- package/dist/index.cjs +31 -31
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.global.js +45 -45
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +32 -32
- package/dist/index.js.map +1 -1
- package/dist/theme-editor.cjs +67 -30
- package/dist/theme-editor.js +67 -30
- package/package.json +1 -1
- package/src/client.test.ts +234 -0
- package/src/client.ts +29 -5
- package/src/session.test.ts +151 -0
- package/src/session.ts +78 -21
- package/src/ui.ask-user-question-plugin.test.ts +42 -0
package/src/client.ts
CHANGED
|
@@ -1125,6 +1125,11 @@ export class AgentWidgetClient {
|
|
|
1125
1125
|
};
|
|
1126
1126
|
|
|
1127
1127
|
let assistantMessage: AgentWidgetMessage | null = null;
|
|
1128
|
+
// Tracks the most recently touched assistant text message for the
|
|
1129
|
+
// current agent turn so `agent_turn_complete.stopReason` can attach
|
|
1130
|
+
// to the final visible text segment even after `assistantMessage`
|
|
1131
|
+
// has been finalized at a tool-call boundary within the turn.
|
|
1132
|
+
let lastAssistantInTurn: AgentWidgetMessage | null = null;
|
|
1128
1133
|
// Reference to track assistant message for custom event handler
|
|
1129
1134
|
const assistantMessageRef = { current: null as AgentWidgetMessage | null };
|
|
1130
1135
|
// Track current partId for message segmentation at tool boundaries
|
|
@@ -2464,7 +2469,11 @@ export class AgentWidgetClient {
|
|
|
2464
2469
|
}
|
|
2465
2470
|
}
|
|
2466
2471
|
} else if (payloadType === "agent_turn_start") {
|
|
2467
|
-
//
|
|
2472
|
+
// Reset the per-turn assistant tracker. lastAssistantInTurn is
|
|
2473
|
+
// used by agent_turn_complete to attach stopReason to the final
|
|
2474
|
+
// text segment of the turn even if that segment was sealed by an
|
|
2475
|
+
// intervening tool-call boundary.
|
|
2476
|
+
lastAssistantInTurn = null;
|
|
2468
2477
|
} else if (payloadType === "agent_turn_delta") {
|
|
2469
2478
|
if (payload.contentType === 'text') {
|
|
2470
2479
|
// Stream text to assistant message
|
|
@@ -2476,6 +2485,7 @@ export class AgentWidgetClient {
|
|
|
2476
2485
|
turnId: payload.turnId,
|
|
2477
2486
|
agentName: agentExecution?.agentName
|
|
2478
2487
|
};
|
|
2488
|
+
lastAssistantInTurn = assistant;
|
|
2479
2489
|
emitMessage(assistant);
|
|
2480
2490
|
} else if (payload.contentType === 'thinking') {
|
|
2481
2491
|
// Stream thinking content to a reasoning message
|
|
@@ -2526,19 +2536,33 @@ export class AgentWidgetClient {
|
|
|
2526
2536
|
// Attach the turn-level stopReason to the assistant message
|
|
2527
2537
|
// produced by this turn. Only overwrite the current message —
|
|
2528
2538
|
// prior turns already sealed their own stopReason via step_complete.
|
|
2539
|
+
// Falls back to lastAssistantInTurn when the current bubble was
|
|
2540
|
+
// sealed at a tool-call boundary mid-turn, so the notice still
|
|
2541
|
+
// attaches to the final visible text segment.
|
|
2529
2542
|
const turnStopReason = (payload as any).stopReason as
|
|
2530
2543
|
| StopReasonKind
|
|
2531
2544
|
| undefined;
|
|
2532
|
-
|
|
2545
|
+
const stopReasonTarget = assistantMessage ?? lastAssistantInTurn;
|
|
2546
|
+
if (turnStopReason && stopReasonTarget !== null) {
|
|
2533
2547
|
const turnId = payload.turnId;
|
|
2534
2548
|
const matchesTurn =
|
|
2535
|
-
!turnId ||
|
|
2549
|
+
!turnId || stopReasonTarget.agentMetadata?.turnId === turnId;
|
|
2536
2550
|
if (matchesTurn) {
|
|
2537
|
-
|
|
2538
|
-
emitMessage(
|
|
2551
|
+
stopReasonTarget.stopReason = turnStopReason;
|
|
2552
|
+
emitMessage(stopReasonTarget);
|
|
2539
2553
|
}
|
|
2540
2554
|
}
|
|
2541
2555
|
} else if (payloadType === "agent_tool_start") {
|
|
2556
|
+
// Finalize any in-flight assistant text bubble so subsequent text
|
|
2557
|
+
// deltas in this turn create a NEW bubble. Without this, text
|
|
2558
|
+
// emitted before AND after a tool call accumulates into one
|
|
2559
|
+
// message that renders below all the tool bubbles, losing the
|
|
2560
|
+
// chronological text→tool→text→tool interleaving.
|
|
2561
|
+
if (assistantMessage) {
|
|
2562
|
+
assistantMessage.streaming = false;
|
|
2563
|
+
emitMessage(assistantMessage);
|
|
2564
|
+
assistantMessage = null;
|
|
2565
|
+
}
|
|
2542
2566
|
const toolId = payload.toolCallId ?? `agent-tool-${nextSequence()}`;
|
|
2543
2567
|
trackToolId(getToolCallKey(payload), toolId);
|
|
2544
2568
|
const toolMessage = ensureToolMessage(toolId);
|
package/src/session.test.ts
CHANGED
|
@@ -642,4 +642,155 @@ describe('AgentWidgetSession.resolveAskUserQuestion', () => {
|
|
|
642
642
|
expect(errors.length).toBe(1);
|
|
643
643
|
expect(errors[0].message).toMatch(/executionId/);
|
|
644
644
|
});
|
|
645
|
+
|
|
646
|
+
it('flips streaming=true BEFORE the resumeFlow fetch resolves (so the typing indicator shows during the silent gap)', async () => {
|
|
647
|
+
const awaiting = makeAwaitingMessage();
|
|
648
|
+
|
|
649
|
+
// Defer fetch resolution so we can observe state mid-await.
|
|
650
|
+
let resolveFetch!: (value: unknown) => void;
|
|
651
|
+
const fetchPromise = new Promise((res) => { resolveFetch = res; });
|
|
652
|
+
global.fetch = vi.fn().mockImplementation(() => fetchPromise);
|
|
653
|
+
|
|
654
|
+
const streamingEvents: boolean[] = [];
|
|
655
|
+
const session = new AgentWidgetSession(
|
|
656
|
+
{
|
|
657
|
+
apiUrl: 'http://localhost:43111/api/chat/dispatch',
|
|
658
|
+
initialMessages: [awaiting],
|
|
659
|
+
},
|
|
660
|
+
{
|
|
661
|
+
onMessagesChanged: () => {},
|
|
662
|
+
onStatusChanged: () => {},
|
|
663
|
+
onStreamingChanged: (s) => { streamingEvents.push(s); },
|
|
664
|
+
}
|
|
665
|
+
);
|
|
666
|
+
|
|
667
|
+
vi.spyOn(session, 'connectStream').mockResolvedValue(undefined);
|
|
668
|
+
|
|
669
|
+
// Kick off — don't await.
|
|
670
|
+
const resolvePromise = session.resolveAskUserQuestion(awaiting, 'Hobbyists');
|
|
671
|
+
|
|
672
|
+
// Yield a microtask so synchronous setup (markResolved → setStreaming(true)
|
|
673
|
+
// → message injection) runs before we observe.
|
|
674
|
+
await Promise.resolve();
|
|
675
|
+
|
|
676
|
+
expect(session.isStreaming()).toBe(true);
|
|
677
|
+
expect(streamingEvents).toEqual([true]);
|
|
678
|
+
|
|
679
|
+
// Now resolve the fetch with a body so the rest of the flow runs.
|
|
680
|
+
const encoder = new TextEncoder();
|
|
681
|
+
const stream = new ReadableStream({
|
|
682
|
+
start(controller) {
|
|
683
|
+
controller.enqueue(encoder.encode('data: {"type":"flow_complete","success":true}\n\n'));
|
|
684
|
+
controller.close();
|
|
685
|
+
},
|
|
686
|
+
});
|
|
687
|
+
resolveFetch({ ok: true, body: stream });
|
|
688
|
+
|
|
689
|
+
await resolvePromise;
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
it('flips streaming=false on the error path when resume rejects', async () => {
|
|
693
|
+
const awaiting = makeAwaitingMessage();
|
|
694
|
+
|
|
695
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
696
|
+
ok: false,
|
|
697
|
+
status: 500,
|
|
698
|
+
json: async () => ({ error: 'boom' }),
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
const streamingEvents: boolean[] = [];
|
|
702
|
+
const errors: Error[] = [];
|
|
703
|
+
const session = new AgentWidgetSession(
|
|
704
|
+
{
|
|
705
|
+
apiUrl: 'http://localhost:43111/api/chat/dispatch',
|
|
706
|
+
initialMessages: [awaiting],
|
|
707
|
+
},
|
|
708
|
+
{
|
|
709
|
+
onMessagesChanged: () => {},
|
|
710
|
+
onStatusChanged: () => {},
|
|
711
|
+
onStreamingChanged: (s) => { streamingEvents.push(s); },
|
|
712
|
+
onError: (e) => errors.push(e),
|
|
713
|
+
}
|
|
714
|
+
);
|
|
715
|
+
|
|
716
|
+
await session.resolveAskUserQuestion(awaiting, 'Hobbyists');
|
|
717
|
+
|
|
718
|
+
expect(streamingEvents[0]).toBe(true);
|
|
719
|
+
expect(streamingEvents[streamingEvents.length - 1]).toBe(false);
|
|
720
|
+
expect(session.isStreaming()).toBe(false);
|
|
721
|
+
expect(errors.length).toBe(1);
|
|
722
|
+
});
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
describe('AgentWidgetSession.resolveApproval', () => {
|
|
726
|
+
const makeApproval = () => ({
|
|
727
|
+
id: 'approval-1',
|
|
728
|
+
status: 'pending' as const,
|
|
729
|
+
agentId: 'agent_abc',
|
|
730
|
+
executionId: 'exec_abc',
|
|
731
|
+
toolName: 'send_email',
|
|
732
|
+
description: 'Send an email',
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
it('flips streaming=true BEFORE the approval fetch resolves', async () => {
|
|
736
|
+
let resolveFetch!: (value: unknown) => void;
|
|
737
|
+
const fetchPromise = new Promise((res) => { resolveFetch = res; });
|
|
738
|
+
global.fetch = vi.fn().mockImplementation(() => fetchPromise);
|
|
739
|
+
|
|
740
|
+
const streamingEvents: boolean[] = [];
|
|
741
|
+
const session = new AgentWidgetSession(
|
|
742
|
+
{ apiUrl: 'http://localhost:43111/api/chat/dispatch' },
|
|
743
|
+
{
|
|
744
|
+
onMessagesChanged: () => {},
|
|
745
|
+
onStatusChanged: () => {},
|
|
746
|
+
onStreamingChanged: (s) => { streamingEvents.push(s); },
|
|
747
|
+
}
|
|
748
|
+
);
|
|
749
|
+
|
|
750
|
+
vi.spyOn(session, 'connectStream').mockResolvedValue(undefined);
|
|
751
|
+
|
|
752
|
+
const resolvePromise = session.resolveApproval(makeApproval(), 'approved');
|
|
753
|
+
|
|
754
|
+
await Promise.resolve();
|
|
755
|
+
|
|
756
|
+
expect(session.isStreaming()).toBe(true);
|
|
757
|
+
expect(streamingEvents).toEqual([true]);
|
|
758
|
+
|
|
759
|
+
// Resolve with a body so the rest of the flow runs through connectStream.
|
|
760
|
+
const encoder = new TextEncoder();
|
|
761
|
+
const stream = new ReadableStream({
|
|
762
|
+
start(controller) {
|
|
763
|
+
controller.enqueue(encoder.encode('data: {"type":"flow_complete","success":true}\n\n'));
|
|
764
|
+
controller.close();
|
|
765
|
+
},
|
|
766
|
+
});
|
|
767
|
+
resolveFetch({ ok: true, body: stream });
|
|
768
|
+
|
|
769
|
+
await resolvePromise;
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
it('flips streaming=false on the error path when approval rejects', async () => {
|
|
773
|
+
// The approval response goes through `instanceof Response` checks, so
|
|
774
|
+
// a non-Response mock that "errors" needs to be modeled as a thrown fetch.
|
|
775
|
+
global.fetch = vi.fn().mockRejectedValue(new Error('network boom'));
|
|
776
|
+
|
|
777
|
+
const streamingEvents: boolean[] = [];
|
|
778
|
+
const errors: Error[] = [];
|
|
779
|
+
const session = new AgentWidgetSession(
|
|
780
|
+
{ apiUrl: 'http://localhost:43111/api/chat/dispatch' },
|
|
781
|
+
{
|
|
782
|
+
onMessagesChanged: () => {},
|
|
783
|
+
onStatusChanged: () => {},
|
|
784
|
+
onStreamingChanged: (s) => { streamingEvents.push(s); },
|
|
785
|
+
onError: (e) => errors.push(e),
|
|
786
|
+
}
|
|
787
|
+
);
|
|
788
|
+
|
|
789
|
+
await session.resolveApproval(makeApproval(), 'approved');
|
|
790
|
+
|
|
791
|
+
expect(streamingEvents[0]).toBe(true);
|
|
792
|
+
expect(streamingEvents[streamingEvents.length - 1]).toBe(false);
|
|
793
|
+
expect(session.isStreaming()).toBe(false);
|
|
794
|
+
expect(errors.length).toBe(1);
|
|
795
|
+
});
|
|
645
796
|
});
|
package/src/session.ts
CHANGED
|
@@ -908,10 +908,12 @@ export class AgentWidgetSession {
|
|
|
908
908
|
*/
|
|
909
909
|
public async connectStream(
|
|
910
910
|
stream: ReadableStream<Uint8Array>,
|
|
911
|
-
options?: { assistantMessageId?: string }
|
|
911
|
+
options?: { assistantMessageId?: string; allowReentry?: boolean }
|
|
912
912
|
): Promise<void> {
|
|
913
|
-
if (this.streaming) return;
|
|
914
|
-
|
|
913
|
+
if (this.streaming && !options?.allowReentry) return;
|
|
914
|
+
if (!options?.allowReentry) {
|
|
915
|
+
this.abortController?.abort();
|
|
916
|
+
}
|
|
915
917
|
|
|
916
918
|
// Finalize any stale streaming messages from the previous stream
|
|
917
919
|
// (e.g., tool messages interrupted by approval pause)
|
|
@@ -971,6 +973,13 @@ export class AgentWidgetSession {
|
|
|
971
973
|
};
|
|
972
974
|
this.upsertMessage(updatedMessage);
|
|
973
975
|
|
|
976
|
+
// Show the standalone typing indicator immediately while we wait for the
|
|
977
|
+
// approval round-trip. Install an abortController so cancel() works during
|
|
978
|
+
// the silent gap. See `resolveAskUserQuestion` for the same pattern.
|
|
979
|
+
this.abortController?.abort();
|
|
980
|
+
this.abortController = new AbortController();
|
|
981
|
+
this.setStreaming(true);
|
|
982
|
+
|
|
974
983
|
// 2. Call onDecision callback if provided, otherwise use client.resolveApproval()
|
|
975
984
|
const approvalConfig = this.config.approval;
|
|
976
985
|
const onDecision = approvalConfig && typeof approvalConfig === 'object' ? approvalConfig.onDecision : undefined;
|
|
@@ -1015,23 +1024,44 @@ export class AgentWidgetSession {
|
|
|
1015
1024
|
}
|
|
1016
1025
|
|
|
1017
1026
|
if (stream) {
|
|
1018
|
-
await this.connectStream(stream);
|
|
1019
|
-
} else
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1027
|
+
await this.connectStream(stream, { allowReentry: true });
|
|
1028
|
+
} else {
|
|
1029
|
+
if (decision === 'denied') {
|
|
1030
|
+
// No stream body for denied — inject a denial message
|
|
1031
|
+
this.appendMessage({
|
|
1032
|
+
id: `denial-${approval.id}`,
|
|
1033
|
+
role: "assistant",
|
|
1034
|
+
content: "Tool execution was denied by user.",
|
|
1035
|
+
createdAt: new Date().toISOString(),
|
|
1036
|
+
streaming: false,
|
|
1037
|
+
sequence: this.nextSequence(),
|
|
1038
|
+
});
|
|
1039
|
+
}
|
|
1040
|
+
// No body to pipe — drop the pre-set streaming flag so the indicator
|
|
1041
|
+
// doesn't linger forever.
|
|
1042
|
+
this.setStreaming(false);
|
|
1043
|
+
this.abortController = null;
|
|
1029
1044
|
}
|
|
1045
|
+
} else {
|
|
1046
|
+
// onDecision returned void / no response — drop the pre-set flag.
|
|
1047
|
+
this.setStreaming(false);
|
|
1048
|
+
this.abortController = null;
|
|
1030
1049
|
}
|
|
1031
1050
|
} catch (error) {
|
|
1032
|
-
|
|
1033
|
-
error instanceof Error
|
|
1034
|
-
|
|
1051
|
+
const isAbortError =
|
|
1052
|
+
error instanceof Error &&
|
|
1053
|
+
(error.name === 'AbortError' ||
|
|
1054
|
+
error.message.includes('aborted') ||
|
|
1055
|
+
error.message.includes('abort'));
|
|
1056
|
+
|
|
1057
|
+
this.setStreaming(false);
|
|
1058
|
+
this.abortController = null;
|
|
1059
|
+
|
|
1060
|
+
if (!isAbortError) {
|
|
1061
|
+
this.callbacks.onError?.(
|
|
1062
|
+
error instanceof Error ? error : new Error(String(error))
|
|
1063
|
+
);
|
|
1064
|
+
}
|
|
1035
1065
|
}
|
|
1036
1066
|
}
|
|
1037
1067
|
|
|
@@ -1143,6 +1173,15 @@ export class AgentWidgetSession {
|
|
|
1143
1173
|
}
|
|
1144
1174
|
this.markAskUserQuestionResolved(toolMessage, structuredAnswers);
|
|
1145
1175
|
|
|
1176
|
+
// Show the standalone typing indicator immediately — the network round-trip
|
|
1177
|
+
// to /resume is otherwise silent, which reads as broken. The render
|
|
1178
|
+
// condition in ui.ts already shows the indicator once streaming flips true
|
|
1179
|
+
// and the last message is a user bubble (the answer we inject below).
|
|
1180
|
+
// Install an abortController so cancel() works during this silent gap.
|
|
1181
|
+
this.abortController?.abort();
|
|
1182
|
+
this.abortController = new AbortController();
|
|
1183
|
+
this.setStreaming(true);
|
|
1184
|
+
|
|
1146
1185
|
// Inject Q→A pair messages — one assistant bubble per question, one user
|
|
1147
1186
|
// bubble per answer — so the transcript reads like a normal conversation.
|
|
1148
1187
|
// The original ask_user_question tool message is suppressed by the
|
|
@@ -1214,12 +1253,30 @@ export class AgentWidgetSession {
|
|
|
1214
1253
|
}
|
|
1215
1254
|
|
|
1216
1255
|
if (response.body) {
|
|
1217
|
-
await this.connectStream(response.body);
|
|
1256
|
+
await this.connectStream(response.body, { allowReentry: true });
|
|
1257
|
+
} else {
|
|
1258
|
+
// No body to pipe — drop the pre-set streaming flag so the indicator
|
|
1259
|
+
// doesn't linger forever.
|
|
1260
|
+
this.setStreaming(false);
|
|
1261
|
+
this.abortController = null;
|
|
1218
1262
|
}
|
|
1219
1263
|
} catch (error) {
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1264
|
+
// Mirror sendMessage: a cancel() during the await aborts the controller
|
|
1265
|
+
// and surfaces an AbortError — don't treat that as a real failure.
|
|
1266
|
+
const isAbortError =
|
|
1267
|
+
error instanceof Error &&
|
|
1268
|
+
(error.name === 'AbortError' ||
|
|
1269
|
+
error.message.includes('aborted') ||
|
|
1270
|
+
error.message.includes('abort'));
|
|
1271
|
+
|
|
1272
|
+
this.setStreaming(false);
|
|
1273
|
+
this.abortController = null;
|
|
1274
|
+
|
|
1275
|
+
if (!isAbortError) {
|
|
1276
|
+
this.callbacks.onError?.(
|
|
1277
|
+
error instanceof Error ? error : new Error(String(error))
|
|
1278
|
+
);
|
|
1279
|
+
}
|
|
1223
1280
|
}
|
|
1224
1281
|
}
|
|
1225
1282
|
|
|
@@ -598,6 +598,48 @@ describe("renderAskUserQuestion plugin hook", () => {
|
|
|
598
598
|
controller.destroy();
|
|
599
599
|
});
|
|
600
600
|
|
|
601
|
+
it("shows the standalone typing indicator immediately after picking an option (resumeFlow pending)", async () => {
|
|
602
|
+
// Defer the fetch response so we observe the DOM during the silent gap
|
|
603
|
+
// between the user's pick and the next streamed token.
|
|
604
|
+
let resolveFetch!: (value: unknown) => void;
|
|
605
|
+
const fetchPromise = new Promise((res) => { resolveFetch = res; });
|
|
606
|
+
global.fetch = vi.fn().mockImplementation(() => fetchPromise) as unknown as typeof fetch;
|
|
607
|
+
|
|
608
|
+
const mount = createMount();
|
|
609
|
+
const controller = createAgentExperience(mount, {
|
|
610
|
+
apiUrl: "https://api.example.com/chat",
|
|
611
|
+
launcher: { enabled: false },
|
|
612
|
+
} as unknown as Parameters<typeof createAgentExperience>[1]);
|
|
613
|
+
|
|
614
|
+
injectAskUserQuestion(controller);
|
|
615
|
+
|
|
616
|
+
// Pre-condition: no typing indicator before the user picks.
|
|
617
|
+
expect(mount.querySelector('[data-typing-indicator="true"]')).toBeNull();
|
|
618
|
+
|
|
619
|
+
const sheet = mount.querySelector<HTMLElement>("[data-persona-ask-sheet-for]")!;
|
|
620
|
+
(sheet.querySelector('[data-option-label="Hobbyists"]') as HTMLElement).click();
|
|
621
|
+
|
|
622
|
+
// Let the synchronous setStreaming(true) + injectMessage path run through.
|
|
623
|
+
await Promise.resolve();
|
|
624
|
+
await Promise.resolve();
|
|
625
|
+
|
|
626
|
+
// The typing indicator should be visible while resumeFlow is pending.
|
|
627
|
+
expect(mount.querySelector('[data-typing-indicator="true"]')).not.toBeNull();
|
|
628
|
+
|
|
629
|
+
// Drain the pending fetch so the test doesn't leak.
|
|
630
|
+
resolveFetch({
|
|
631
|
+
ok: true,
|
|
632
|
+
body: new ReadableStream({
|
|
633
|
+
start(c) {
|
|
634
|
+
c.enqueue(new TextEncoder().encode('data: {"type":"flow_complete","success":true}\n\n'));
|
|
635
|
+
c.close();
|
|
636
|
+
},
|
|
637
|
+
}),
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
controller.destroy();
|
|
641
|
+
});
|
|
642
|
+
|
|
601
643
|
it("wires resolve(answer) to session.resolveAskUserQuestion via /resume", async () => {
|
|
602
644
|
const fetchMock = vi.fn().mockResolvedValue({
|
|
603
645
|
ok: true,
|