@runtypelabs/persona 4.6.1 → 4.8.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.
Files changed (120) hide show
  1. package/dist/animations/glyph-cycle.cjs +2 -2
  2. package/dist/animations/glyph-cycle.d.cts +1 -1
  3. package/dist/animations/glyph-cycle.d.ts +1 -1
  4. package/dist/animations/glyph-cycle.js +2 -2
  5. package/dist/animations/{types-CSmiKRVa.d.cts → types-BsZtXPKK.d.cts} +43 -3
  6. package/dist/animations/{types-CSmiKRVa.d.ts → types-BsZtXPKK.d.ts} +43 -3
  7. package/dist/animations/wipe.cjs +2 -2
  8. package/dist/animations/wipe.d.cts +1 -1
  9. package/dist/animations/wipe.d.ts +1 -1
  10. package/dist/chunk-5EIIHQLQ.js +1 -0
  11. package/dist/codegen.cjs +12 -12
  12. package/dist/codegen.js +14 -14
  13. package/dist/index.cjs +91 -68
  14. package/dist/index.cjs.map +1 -1
  15. package/dist/index.d.cts +918 -174
  16. package/dist/index.d.ts +918 -174
  17. package/dist/index.global.js +81 -57
  18. package/dist/index.global.js.map +1 -1
  19. package/dist/index.js +89 -66
  20. package/dist/index.js.map +1 -1
  21. package/dist/install.global.js +1 -1
  22. package/dist/install.global.js.map +1 -1
  23. package/dist/launcher.global.js +3 -2
  24. package/dist/launcher.global.js.map +1 -1
  25. package/dist/markdown-parsers.js +24 -24
  26. package/dist/plugin-kit.cjs +1 -1
  27. package/dist/plugin-kit.js +1 -1
  28. package/dist/runtype-tts-entry-UJAEF7NZ.js +1 -0
  29. package/dist/runtype-tts.js +1 -1
  30. package/dist/session-reconnect-JKIJBHS5.js +1 -0
  31. package/dist/smart-dom-reader.cjs +17 -17
  32. package/dist/smart-dom-reader.d.cts +753 -15
  33. package/dist/smart-dom-reader.d.ts +753 -15
  34. package/dist/smart-dom-reader.js +17 -17
  35. package/dist/testing.cjs +3 -3
  36. package/dist/testing.js +3 -3
  37. package/dist/theme-editor-preview.cjs +81 -58
  38. package/dist/theme-editor-preview.d.cts +761 -15
  39. package/dist/theme-editor-preview.d.ts +761 -15
  40. package/dist/theme-editor-preview.js +81 -58
  41. package/dist/theme-editor.cjs +6 -6
  42. package/dist/theme-editor.d.cts +753 -15
  43. package/dist/theme-editor.d.ts +753 -15
  44. package/dist/theme-editor.js +10 -10
  45. package/dist/theme-reference.cjs +1 -1
  46. package/dist/theme-reference.d.cts +74 -0
  47. package/dist/theme-reference.d.ts +74 -0
  48. package/dist/theme-reference.js +1 -1
  49. package/dist/voice-worklet-player.cjs +2 -2
  50. package/dist/voice-worklet-player.js +2 -2
  51. package/dist/webmcp-polyfill.js +2 -2
  52. package/dist/widget.css +1 -1
  53. package/package.json +2 -3
  54. package/src/artifacts-session.test.ts +178 -0
  55. package/src/client.test.ts +500 -1
  56. package/src/client.ts +285 -93
  57. package/src/components/artifact-card.test.ts +333 -0
  58. package/src/components/artifact-card.ts +75 -28
  59. package/src/components/artifact-inline.test.ts +1328 -0
  60. package/src/components/artifact-inline.ts +920 -0
  61. package/src/components/artifact-pane.test.ts +1042 -0
  62. package/src/components/artifact-pane.ts +440 -131
  63. package/src/components/artifact-preview.test.ts +1155 -0
  64. package/src/components/artifact-preview.ts +994 -0
  65. package/src/components/pill-composer-builder.test.ts +6 -2
  66. package/src/components/pill-composer-builder.ts +6 -6
  67. package/src/components/reasoning-bubble.ts +1 -13
  68. package/src/components/registry.ts +38 -3
  69. package/src/components/tool-bubble.ts +1 -13
  70. package/src/defaults.ts +1 -0
  71. package/src/generated/runtype-openapi-contract.ts +55 -3
  72. package/src/index-core.ts +20 -1
  73. package/src/index.ts +8 -0
  74. package/src/markdown-parsers-loader.test.ts +158 -0
  75. package/src/markdown-parsers-loader.ts +74 -9
  76. package/src/runtime/host-layout.test.ts +163 -0
  77. package/src/runtime/host-layout.ts +110 -7
  78. package/src/runtime/init.ts +18 -61
  79. package/src/runtime/persist-state.test.ts +118 -0
  80. package/src/session.ts +76 -22
  81. package/src/styles/widget.css +773 -26
  82. package/src/theme-editor/preview.ts +2 -0
  83. package/src/theme-editor/sections.test.ts +26 -1
  84. package/src/theme-editor/sections.ts +10 -2
  85. package/src/theme-reference.ts +2 -2
  86. package/src/tool-call-display-defaults.test.ts +1 -0
  87. package/src/types/theme.ts +77 -0
  88. package/src/types.ts +516 -17
  89. package/src/ui.artifact-pane-gating.test.ts +636 -0
  90. package/src/ui.component-directive.test.ts +104 -0
  91. package/src/ui.composer-bar.test.ts +60 -2
  92. package/src/ui.detached-panel.test.ts +1049 -0
  93. package/src/ui.scroll-additive.test.ts +1 -1
  94. package/src/ui.streaming-coalescing.test.ts +312 -0
  95. package/src/ui.tool-display.test.ts +51 -0
  96. package/src/ui.ts +933 -214
  97. package/src/utils/artifact-custom-actions.ts +128 -0
  98. package/src/utils/artifact-display.test.ts +42 -0
  99. package/src/utils/artifact-display.ts +84 -0
  100. package/src/utils/artifact-file.test.ts +116 -0
  101. package/src/utils/artifact-file.ts +117 -0
  102. package/src/utils/artifact-gate.test.ts +112 -5
  103. package/src/utils/artifact-gate.ts +39 -14
  104. package/src/utils/artifact-loading-status.ts +55 -0
  105. package/src/utils/artifact-status-label.ts +190 -0
  106. package/src/utils/buttons.ts +7 -1
  107. package/src/utils/code-highlight.test.ts +186 -0
  108. package/src/utils/code-highlight.ts +400 -0
  109. package/src/utils/icons.ts +2 -0
  110. package/src/utils/roving-tablist.test.ts +152 -0
  111. package/src/utils/roving-tablist.ts +111 -0
  112. package/src/utils/spinner.ts +45 -0
  113. package/src/utils/theme.test.ts +48 -0
  114. package/src/utils/theme.ts +7 -0
  115. package/src/utils/tokens.ts +91 -0
  116. package/src/utils/tool-loading-animation.test.ts +32 -0
  117. package/src/utils/tool-loading-animation.ts +24 -0
  118. package/dist/chunk-DFBSCFYN.js +0 -1
  119. package/dist/runtype-tts-entry-HFUV2UF7.js +0 -1
  120. package/dist/session-reconnect-U77QFUR7.js +0 -1
@@ -1,10 +1,90 @@
1
1
  import { describe, it, expect, beforeEach, vi } from 'vitest';
2
2
  import { AgentWidgetClient, preferFinalStructuredContent } from './client';
3
- import { AgentWidgetEvent, AgentWidgetMessage } from './types';
3
+ import { AgentWidgetEvent, AgentWidgetMessage, AgentWidgetArtifactsFeature } from './types';
4
4
  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();
@@ -4043,3 +4357,188 @@ describe('AgentWidgetClient - Wire event vocabulary (default in 4.0)', () => {
4043
4357
  }
4044
4358
  });
4045
4359
  });
4360
+
4361
+ describe('AgentWidgetClient - Artifact file metadata', () => {
4362
+ const artifactFrames = (fileField: string | null): string[] => [
4363
+ `data: {"type":"artifact_start","id":"a1","artifactType":"markdown","title":"outputs/cat.html"${
4364
+ fileField === null ? '' : ',"file":' + fileField
4365
+ }}\n\n`,
4366
+ `data: {"type":"artifact_delta","id":"a1","delta":${JSON.stringify('```html\n<h1>hi</h1>\n```')}}\n\n`,
4367
+ `data: {"type":"artifact_complete","id":"a1"}\n\n`,
4368
+ ];
4369
+
4370
+ it('propagates valid file metadata to the artifact_start event and card props', async () => {
4371
+ global.fetch = createRawStreamFetch(
4372
+ artifactFrames('{"path":"outputs/cat.html","mimeType":"text/html","language":"html"}')
4373
+ );
4374
+ const client = new AgentWidgetClient({ apiUrl: 'http://localhost:8000' });
4375
+ const events: AgentWidgetEvent[] = [];
4376
+ await client.dispatch(
4377
+ { messages: [{ id: 'u1', role: 'user', content: 'Hi', createdAt: new Date().toISOString() }] },
4378
+ (e) => events.push(e)
4379
+ );
4380
+
4381
+ const startEv = events.find((e) => e.type === 'artifact_start');
4382
+ expect(startEv).toBeDefined();
4383
+ if (startEv?.type === 'artifact_start') {
4384
+ expect(startEv.file).toEqual({
4385
+ path: 'outputs/cat.html',
4386
+ mimeType: 'text/html',
4387
+ language: 'html',
4388
+ });
4389
+ }
4390
+
4391
+ // The completed card message carries the file metadata in its rawContent props.
4392
+ const cardMsgs = events.filter(
4393
+ (e): e is Extract<AgentWidgetEvent, { type: 'message' }> =>
4394
+ e.type === 'message' && e.message.id === 'artifact-ref-a1'
4395
+ );
4396
+ const lastCard = cardMsgs[cardMsgs.length - 1];
4397
+ expect(lastCard).toBeDefined();
4398
+ const props = JSON.parse(lastCard.message.rawContent ?? '{}').props;
4399
+ expect(props.status).toBe('complete');
4400
+ expect(props.file).toEqual({
4401
+ path: 'outputs/cat.html',
4402
+ mimeType: 'text/html',
4403
+ language: 'html',
4404
+ });
4405
+ expect(props.markdown).toBe('```html\n<h1>hi</h1>\n```');
4406
+ });
4407
+
4408
+ it('drops malformed file metadata without error', async () => {
4409
+ global.fetch = createRawStreamFetch(artifactFrames('{"path":123}'));
4410
+ const client = new AgentWidgetClient({ apiUrl: 'http://localhost:8000' });
4411
+ const events: AgentWidgetEvent[] = [];
4412
+ await client.dispatch(
4413
+ { messages: [{ id: 'u1', role: 'user', content: 'Hi', createdAt: new Date().toISOString() }] },
4414
+ (e) => events.push(e)
4415
+ );
4416
+
4417
+ expect(events.some((e) => e.type === 'error')).toBe(false);
4418
+ const startEv = events.find((e) => e.type === 'artifact_start');
4419
+ expect(startEv).toBeDefined();
4420
+ if (startEv?.type === 'artifact_start') {
4421
+ expect(startEv.file).toBeUndefined();
4422
+ }
4423
+ const cardMsgs = events.filter(
4424
+ (e): e is Extract<AgentWidgetEvent, { type: 'message' }> =>
4425
+ e.type === 'message' && e.message.id === 'artifact-ref-a1'
4426
+ );
4427
+ const props = JSON.parse(cardMsgs[cardMsgs.length - 1].message.rawContent ?? '{}').props;
4428
+ expect(props.file).toBeUndefined();
4429
+ });
4430
+ });
4431
+
4432
+ describe('AgentWidgetClient - Artifact display modes', () => {
4433
+ const markdownArtifactFrames = (id = 'a1'): string[] => [
4434
+ `data: {"type":"artifact_start","id":"${id}","artifactType":"markdown","title":"Notes"}\n\n`,
4435
+ `data: {"type":"artifact_delta","id":"${id}","delta":"# Hello"}\n\n`,
4436
+ `data: {"type":"artifact_complete","id":"${id}"}\n\n`,
4437
+ ];
4438
+ const componentArtifactFrames = (id = 'c1'): string[] => [
4439
+ `data: {"type":"artifact_start","id":"${id}","artifactType":"component","title":"Chart","component":"MyChart"}\n\n`,
4440
+ `data: {"type":"artifact_complete","id":"${id}"}\n\n`,
4441
+ ];
4442
+
4443
+ const dispatchAndCollect = async (
4444
+ frames: string[],
4445
+ artifacts?: AgentWidgetArtifactsFeature
4446
+ ): Promise<AgentWidgetEvent[]> => {
4447
+ global.fetch = createRawStreamFetch(frames);
4448
+ const client = new AgentWidgetClient({
4449
+ apiUrl: 'http://localhost:8000',
4450
+ ...(artifacts ? { features: { artifacts } } : {}),
4451
+ });
4452
+ const events: AgentWidgetEvent[] = [];
4453
+ await client.dispatch(
4454
+ { messages: [{ id: 'u1', role: 'user', content: 'Hi', createdAt: new Date().toISOString() }] },
4455
+ (e) => events.push(e)
4456
+ );
4457
+ return events;
4458
+ };
4459
+
4460
+ const blockMessagesFor = (events: AgentWidgetEvent[], artifactId: string) =>
4461
+ events.filter(
4462
+ (e): e is Extract<AgentWidgetEvent, { type: 'message' }> =>
4463
+ e.type === 'message' && e.message.id === `artifact-ref-${artifactId}`
4464
+ );
4465
+
4466
+ const componentNameOf = (msg: AgentWidgetMessage): string =>
4467
+ JSON.parse(msg.rawContent ?? '{}').component;
4468
+
4469
+ it('injects PersonaArtifactCard when display is unset (default "panel")', async () => {
4470
+ const events = await dispatchAndCollect(markdownArtifactFrames());
4471
+ const msgs = blockMessagesFor(events, 'a1');
4472
+ expect(msgs.length).toBeGreaterThan(0);
4473
+ for (const m of msgs) {
4474
+ expect(componentNameOf(m.message)).toBe('PersonaArtifactCard');
4475
+ }
4476
+ });
4477
+
4478
+ it('injects PersonaArtifactCard for display: "card" and display: "panel"', async () => {
4479
+ for (const mode of ['card', 'panel'] as const) {
4480
+ const events = await dispatchAndCollect(markdownArtifactFrames(), {
4481
+ enabled: true,
4482
+ display: mode,
4483
+ });
4484
+ const msgs = blockMessagesFor(events, 'a1');
4485
+ expect(msgs.length).toBeGreaterThan(0);
4486
+ expect(componentNameOf(msgs[0].message)).toBe('PersonaArtifactCard');
4487
+ }
4488
+ });
4489
+
4490
+ it('injects PersonaArtifactInline for display: "inline"', async () => {
4491
+ const events = await dispatchAndCollect(markdownArtifactFrames(), {
4492
+ enabled: true,
4493
+ display: 'inline',
4494
+ });
4495
+ const msgs = blockMessagesFor(events, 'a1');
4496
+ expect(msgs.length).toBeGreaterThan(0);
4497
+ for (const m of msgs) {
4498
+ expect(componentNameOf(m.message)).toBe('PersonaArtifactInline');
4499
+ }
4500
+ const startProps = JSON.parse(msgs[0].message.rawContent ?? '{}').props;
4501
+ expect(startProps).toMatchObject({
4502
+ artifactId: 'a1',
4503
+ artifactType: 'markdown',
4504
+ status: 'streaming',
4505
+ });
4506
+ });
4507
+
4508
+ it('resolves the mode per artifact type via display.byType', async () => {
4509
+ const display: AgentWidgetArtifactsFeature['display'] = {
4510
+ default: 'card',
4511
+ byType: { component: 'inline' },
4512
+ };
4513
+
4514
+ const mdEvents = await dispatchAndCollect(markdownArtifactFrames(), {
4515
+ enabled: true,
4516
+ display,
4517
+ });
4518
+ expect(componentNameOf(blockMessagesFor(mdEvents, 'a1')[0].message)).toBe(
4519
+ 'PersonaArtifactCard'
4520
+ );
4521
+
4522
+ const compEvents = await dispatchAndCollect(componentArtifactFrames(), {
4523
+ enabled: true,
4524
+ display,
4525
+ });
4526
+ const compMsgs = blockMessagesFor(compEvents, 'c1');
4527
+ expect(componentNameOf(compMsgs[0].message)).toBe('PersonaArtifactInline');
4528
+ // Inline component artifacts carry the component name for the preview registry.
4529
+ expect(JSON.parse(compMsgs[0].message.rawContent ?? '{}').props.component).toBe('MyChart');
4530
+ });
4531
+
4532
+ it('embeds accumulated markdown and complete status in inline block props on artifact_complete', async () => {
4533
+ const events = await dispatchAndCollect(markdownArtifactFrames(), {
4534
+ enabled: true,
4535
+ display: 'inline',
4536
+ });
4537
+ const msgs = blockMessagesFor(events, 'a1');
4538
+ const last = msgs[msgs.length - 1];
4539
+ expect(last.message.streaming).toBe(false);
4540
+ const props = JSON.parse(last.message.rawContent ?? '{}').props;
4541
+ expect(props.status).toBe('complete');
4542
+ expect(props.markdown).toBe('# Hello');
4543
+ });
4544
+ });