@runtypelabs/persona 4.17.0 → 4.18.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-BpWGm7_6.d.cts → types-DsmTD8TO.d.cts} +12 -0
- package/dist/animations/{types-BpWGm7_6.d.ts → types-DsmTD8TO.d.ts} +12 -0
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/chunk-3I2S3I5P.js +1 -0
- package/dist/chunk-76IELU7F.js +1 -0
- package/dist/chunk-EFJBUH5F.js +1 -0
- package/dist/chunk-PDPVORUF.js +1 -0
- package/dist/chunk-Q735PSZL.js +1 -0
- package/dist/chunk-VK6JOIYJ.js +1 -0
- package/dist/codegen.cjs +4 -4
- package/dist/codegen.js +4 -4
- package/dist/{context-mentions-BTSTIBFC.js → context-mentions-MJL6S6P2.js} +1 -1
- package/dist/context-mentions.cjs +9 -8
- package/dist/context-mentions.d.cts +1224 -14
- package/dist/context-mentions.d.ts +1224 -14
- package/dist/context-mentions.js +9 -8
- package/dist/event-stream-view-4EZ7BGFK.js +1 -0
- package/dist/event-stream-view.cjs +129 -0
- package/dist/event-stream-view.d.cts +8384 -0
- package/dist/event-stream-view.d.ts +8384 -0
- package/dist/event-stream-view.js +129 -0
- package/dist/history-view-BD6OXLNA.js +898 -0
- package/dist/history-view.cjs +898 -0
- package/dist/history-view.d.cts +1461 -0
- package/dist/history-view.d.ts +1461 -0
- package/dist/history-view.js +898 -0
- package/dist/index.cjs +77 -77
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2040 -21
- package/dist/index.d.ts +2040 -21
- package/dist/index.global.js +67 -66
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +77 -77
- package/dist/index.js.map +1 -1
- package/dist/launcher.global.js +5 -4
- package/dist/launcher.global.js.map +1 -1
- package/dist/plugin-kit.cjs +1 -1
- package/dist/plugin-kit.d.cts +0 -8
- package/dist/plugin-kit.d.ts +0 -8
- package/dist/plugin-kit.js +1 -1
- package/dist/smart-dom-reader.d.cts +1224 -14
- package/dist/smart-dom-reader.d.ts +1224 -14
- package/dist/theme-editor-preview.cjs +970 -73
- package/dist/theme-editor-preview.d.cts +1343 -15
- package/dist/theme-editor-preview.d.ts +1343 -15
- package/dist/theme-editor-preview.js +81 -81
- package/dist/theme-editor.cjs +7 -7
- package/dist/theme-editor.d.cts +1227 -15
- package/dist/theme-editor.d.ts +1227 -15
- package/dist/theme-editor.js +9 -9
- package/dist/theme-reference.cjs +1 -1
- package/dist/theme-reference.d.cts +226 -7
- package/dist/theme-reference.d.ts +226 -7
- package/dist/theme-reference.js +1 -1
- package/dist/webmcp-polyfill.js +2 -2
- package/dist/widget.css +1 -1
- package/package.json +18 -6
- package/src/client-history-rest.test.ts +1034 -0
- package/src/client-visitor-history.test.ts +596 -0
- package/src/client.ts +1040 -24
- package/src/components/artifact-inline.ts +8 -1
- package/src/components/artifact-pane.test.ts +278 -1
- package/src/components/artifact-pane.ts +135 -67
- package/src/components/artifact-preview.test.ts +14 -1
- package/src/components/artifact-preview.ts +9 -0
- package/src/components/header-builder.ts +3 -0
- package/src/components/header-layouts.test.ts +171 -7
- package/src/components/header-layouts.ts +83 -53
- package/src/components/header-parts.test.ts +154 -1
- package/src/components/header-parts.ts +266 -138
- package/src/components/history-confirm-dialog.ts +177 -0
- package/src/components/history-shell-copy.ts +99 -0
- package/src/components/history-view/announcer.ts +40 -0
- package/src/components/history-view/copy.ts +105 -0
- package/src/components/history-view/css.ts +1078 -0
- package/src/components/history-view/grouping.test.ts +142 -0
- package/src/components/history-view/grouping.ts +142 -0
- package/src/components/history-view/icons.ts +73 -0
- package/src/components/history-view/rows.ts +341 -0
- package/src/components/history-view/state.ts +309 -0
- package/src/components/history-view.test.ts +2660 -0
- package/src/components/history-view.ts +1891 -0
- package/src/components/launcher-surface.test.ts +133 -0
- package/src/components/launcher.test.ts +18 -0
- package/src/components/launcher.ts +7 -7
- package/src/components/panel.ts +17 -7
- package/src/components/welcome.ts +72 -2
- package/src/defaults.ts +36 -3
- package/src/event-stream-view-bundle.test.ts +69 -0
- package/src/event-stream-view-entry.ts +19 -0
- package/src/event-stream-view-loader.ts +31 -0
- package/src/event-stream-view.ts +19 -0
- package/src/generated/runtype-openapi-contract.ts +54 -4
- package/src/history-render.ts +325 -0
- package/src/history-view-bundle.test.ts +78 -0
- package/src/history-view-entry.ts +41 -0
- package/src/history-view-loader.test.ts +30 -0
- package/src/history-view-loader.ts +32 -0
- package/src/history-view.ts +28 -0
- package/src/index-core.ts +50 -2
- package/src/index-global.ts +55 -0
- package/src/internal/demo-history-provider.test.ts +402 -0
- package/src/internal/demo-history-provider.ts +689 -0
- package/src/internal/history-provider-registry.ts +25 -0
- package/src/internal/history-provider.ts +150 -0
- package/src/internal/runtype-history-provider.test.ts +313 -0
- package/src/internal/runtype-history-provider.ts +240 -0
- package/src/plugin-kit.test.ts +16 -0
- package/src/plugin-kit.ts +13 -5
- package/src/plugins/types.ts +177 -1
- package/src/session-history.test.ts +1397 -0
- package/src/session.ts +1200 -26
- package/src/styles/widget-styles.test.ts +133 -0
- package/src/styles/widget.css +227 -8
- package/src/theme-editor/preview-utils.test.ts +19 -0
- package/src/theme-editor/preview-utils.ts +15 -1
- package/src/theme-editor/preview.ts +7 -1
- package/src/theme-editor/role-mappings.ts +4 -1
- package/src/theme-editor/sections.test.ts +168 -1
- package/src/theme-editor/sections.ts +136 -7
- package/src/theme-editor/webmcp/summary.ts +16 -2
- package/src/theme-reference.ts +20 -6
- package/src/types/theme.ts +213 -7
- package/src/types.ts +927 -7
- package/src/ui.artifact-rail-resize.test.ts +235 -0
- package/src/ui.attachments-drop.test.ts +1 -0
- package/src/ui.header-update-stability.test.ts +2 -2
- package/src/ui.history-chunk.test.ts +72 -0
- package/src/ui.history-config-provider.test.ts +318 -0
- package/src/ui.history-controller.test.ts +711 -0
- package/src/ui.history-lifecycle.test.ts +456 -0
- package/src/ui.history-render-hooks.test.ts +904 -0
- package/src/ui.history-shell.test.ts +1887 -0
- package/src/ui.rail-overlay.test.ts +573 -0
- package/src/ui.rail-resize.test.ts +284 -0
- package/src/ui.rebuild-composer.test.ts +21 -0
- package/src/ui.render-welcome.test.ts +42 -0
- package/src/ui.shortcuts.test.ts +166 -0
- package/src/ui.ts +3071 -240
- package/src/utils/__fixtures__/client-conversation-history-wire.json +108 -0
- package/src/utils/artifact-file.ts +15 -0
- package/src/utils/artifact-gate.test.ts +4 -0
- package/src/utils/artifact-gate.ts +13 -17
- package/src/utils/artifact-resize.test.ts +28 -0
- package/src/utils/artifact-resize.ts +9 -2
- package/src/utils/buttons.ts +188 -1
- package/src/utils/config-merge.test.ts +91 -0
- package/src/utils/dropdown.ts +10 -1
- package/src/utils/history-messages.test.ts +284 -0
- package/src/utils/history-messages.ts +284 -0
- package/src/utils/icons.ts +3 -0
- package/src/utils/shortcuts.test.ts +288 -0
- package/src/utils/shortcuts.ts +221 -0
- package/src/utils/theme.test.ts +501 -4
- package/src/utils/theme.ts +42 -3
- package/src/utils/tokens.ts +153 -15
- package/src/utils/tooltip-timing.ts +4 -0
- package/src/utils/tooltip.test.ts +249 -7
- package/src/utils/tooltip.ts +146 -4
- package/src/utils/visitor-store.test.ts +425 -0
- package/src/utils/visitor-store.ts +337 -0
- package/dist/chunk-QJ4LV2KW.js +0 -1
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
|
|
4
|
+
import type { AgentWidgetMessage } from "../types";
|
|
5
|
+
import {
|
|
6
|
+
isHistoryDisplayUnavailable,
|
|
7
|
+
mapWireMessages,
|
|
8
|
+
mergeWireMessagesById,
|
|
9
|
+
type HistoryWireConversationSummary,
|
|
10
|
+
type HistoryWireMessage,
|
|
11
|
+
} from "./history-messages";
|
|
12
|
+
|
|
13
|
+
type HistoryWireFixture = {
|
|
14
|
+
list: HistoryWireConversationSummary[];
|
|
15
|
+
detail: {
|
|
16
|
+
id: string;
|
|
17
|
+
messages: HistoryWireMessage[];
|
|
18
|
+
nextMessageCursor: string | null;
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const fixture = JSON.parse(
|
|
23
|
+
readFileSync(
|
|
24
|
+
new URL("./__fixtures__/client-conversation-history-wire.json", import.meta.url),
|
|
25
|
+
"utf8"
|
|
26
|
+
)
|
|
27
|
+
) as HistoryWireFixture;
|
|
28
|
+
|
|
29
|
+
function byId(messages: AgentWidgetMessage[]) {
|
|
30
|
+
return new Map(messages.map((message) => [message.id, message]));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("mapWireMessages: fixture parity", () => {
|
|
34
|
+
const mapped = mapWireMessages(fixture.detail.messages);
|
|
35
|
+
const messages = byId(mapped);
|
|
36
|
+
|
|
37
|
+
it("keeps every renderable wire message in server order", () => {
|
|
38
|
+
expect(mapped.map((message) => message.id)).toEqual([
|
|
39
|
+
"msg_string",
|
|
40
|
+
"msg_divergent",
|
|
41
|
+
"msg_empty_projection",
|
|
42
|
+
"msg_hidden_llm_content",
|
|
43
|
+
"msg_raw_directive",
|
|
44
|
+
"msg_image",
|
|
45
|
+
"msg_file",
|
|
46
|
+
"msg_absent_content",
|
|
47
|
+
"msg_terminal_finalized",
|
|
48
|
+
]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("renders an ordinary string message as-is", () => {
|
|
52
|
+
const message = messages.get("msg_string")!;
|
|
53
|
+
expect(message.role).toBe("user");
|
|
54
|
+
expect(message.content).toBe("Where is my order?");
|
|
55
|
+
expect(message.createdAt).toBe("2026-02-01T00:00:00.000Z");
|
|
56
|
+
expect(message.contentParts).toBeUndefined();
|
|
57
|
+
expect(message.llmContent).toBeUndefined();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("renders the display projection and leaks no model content", () => {
|
|
61
|
+
const message = messages.get("msg_divergent")!;
|
|
62
|
+
expect(message.content).toBe("Order A-1 is on its way.");
|
|
63
|
+
const serialized = JSON.stringify(message);
|
|
64
|
+
expect(serialized).not.toContain("directive");
|
|
65
|
+
expect(serialized).not.toContain("order_card");
|
|
66
|
+
expect(message.llmContent).toBeUndefined();
|
|
67
|
+
expect(message.rawContent).toBeUndefined();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("treats a deliberately empty projection as empty, not as a fallback", () => {
|
|
71
|
+
const message = messages.get("msg_empty_projection")!;
|
|
72
|
+
expect(message.content).toBe("");
|
|
73
|
+
expect(JSON.stringify(message)).not.toContain("open_modal");
|
|
74
|
+
expect(isHistoryDisplayUnavailable(message)).toBe(false);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("marks withheld messages unavailable without any model-only fragment", () => {
|
|
78
|
+
for (const id of ["msg_hidden_llm_content", "msg_raw_directive", "msg_absent_content"]) {
|
|
79
|
+
const message = messages.get(id)!;
|
|
80
|
+
expect(message.content).toBe("");
|
|
81
|
+
expect(isHistoryDisplayUnavailable(message)).toBe(true);
|
|
82
|
+
expect(message.contentParts).toBeUndefined();
|
|
83
|
+
expect(message.llmContent).toBeUndefined();
|
|
84
|
+
expect(message.rawContent).toBeUndefined();
|
|
85
|
+
// Nothing beyond identity, an empty body, and the marker survives.
|
|
86
|
+
expect(Object.keys(message).sort()).toEqual([
|
|
87
|
+
"agentMetadata",
|
|
88
|
+
"content",
|
|
89
|
+
"createdAt",
|
|
90
|
+
"id",
|
|
91
|
+
"role",
|
|
92
|
+
]);
|
|
93
|
+
expect(JSON.stringify(message)).not.toContain("order_card");
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("maps image parts through the multi-modal helpers", () => {
|
|
98
|
+
const message = messages.get("msg_image")!;
|
|
99
|
+
expect(message.contentParts).toEqual([
|
|
100
|
+
{ type: "text", text: "Looks like this:" },
|
|
101
|
+
{ type: "image", image: "data:image/png;base64,AAAA" },
|
|
102
|
+
]);
|
|
103
|
+
expect(message.content).toBe("Looks like this:");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("maps file parts and falls back to empty visible text", () => {
|
|
107
|
+
const message = messages.get("msg_file")!;
|
|
108
|
+
expect(message.contentParts).toEqual([
|
|
109
|
+
{
|
|
110
|
+
type: "file",
|
|
111
|
+
data: "ZmFrZQ==",
|
|
112
|
+
mimeType: "application/pdf",
|
|
113
|
+
filename: "receipt.pdf",
|
|
114
|
+
},
|
|
115
|
+
]);
|
|
116
|
+
expect(message.content).toBe("");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("shows the finalized projection on the terminal assistant turn", () => {
|
|
120
|
+
const message = messages.get("msg_terminal_finalized")!;
|
|
121
|
+
expect(message.role).toBe("assistant");
|
|
122
|
+
expect(message.content).toBe("It is in transit and arrives Thursday.");
|
|
123
|
+
const serialized = JSON.stringify(message);
|
|
124
|
+
expect(serialized).not.toContain("in_transit");
|
|
125
|
+
expect(serialized).not.toContain("track");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("never fabricates a model channel for any mapped message", () => {
|
|
129
|
+
for (const message of mapped) {
|
|
130
|
+
expect(message.llmContent).toBeUndefined();
|
|
131
|
+
expect(message.rawContent).toBeUndefined();
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("exposes the list summary shape the UI paginates", () => {
|
|
136
|
+
const [summary] = fixture.list;
|
|
137
|
+
expect(summary.preview).toBe("It is in transit and arrives Thursday.");
|
|
138
|
+
expect(summary.messageCount).toBe(9);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe("mapWireMessages: image-only fallback", () => {
|
|
143
|
+
it("uses the shared image fallback text when there is no visible text", () => {
|
|
144
|
+
const [message] = mapWireMessages([
|
|
145
|
+
{
|
|
146
|
+
id: "m1",
|
|
147
|
+
role: "user",
|
|
148
|
+
content: [{ type: "image", image: "https://example.com/a.png" }],
|
|
149
|
+
displayAvailable: true,
|
|
150
|
+
timestamp: "2026-02-01T00:00:00.000Z",
|
|
151
|
+
},
|
|
152
|
+
]);
|
|
153
|
+
expect(message.content).toBe("[Image]");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("drops unrecognized parts instead of surfacing them", () => {
|
|
157
|
+
const [message] = mapWireMessages([
|
|
158
|
+
{
|
|
159
|
+
id: "m1",
|
|
160
|
+
role: "assistant",
|
|
161
|
+
content: [
|
|
162
|
+
{ type: "tool_use", name: "track", input: { orderId: "A-1" } },
|
|
163
|
+
{ type: "text", text: "Done." },
|
|
164
|
+
],
|
|
165
|
+
displayAvailable: true,
|
|
166
|
+
timestamp: "2026-02-01T00:00:00.000Z",
|
|
167
|
+
},
|
|
168
|
+
]);
|
|
169
|
+
expect(message.contentParts).toEqual([{ type: "text", text: "Done." }]);
|
|
170
|
+
expect(JSON.stringify(message)).not.toContain("tool_use");
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe("mapWireMessages: role filtering", () => {
|
|
175
|
+
it("drops roles the widget does not render", () => {
|
|
176
|
+
const mapped = mapWireMessages([
|
|
177
|
+
{ id: "a", role: "user", content: "hi", displayAvailable: true },
|
|
178
|
+
{ id: "b", role: "tool", content: "{}", displayAvailable: true },
|
|
179
|
+
{ id: "c", role: "developer", content: "x", displayAvailable: true },
|
|
180
|
+
{ id: "d", role: "system", content: "note", displayAvailable: true },
|
|
181
|
+
{ id: "e", role: "assistant", content: "yo", displayAvailable: true },
|
|
182
|
+
]);
|
|
183
|
+
expect(mapped.map((message) => message.id)).toEqual(["a", "d", "e"]);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe("mapWireMessages: timestamp synthesis", () => {
|
|
188
|
+
const olderPage: HistoryWireMessage[] = [
|
|
189
|
+
{ id: "p1", role: "user", content: "one", displayAvailable: true },
|
|
190
|
+
{ id: "p2", role: "assistant", content: "two", displayAvailable: true },
|
|
191
|
+
{ id: "p3", role: "user", content: "three", displayAvailable: true },
|
|
192
|
+
];
|
|
193
|
+
const bound = "2026-02-01T00:00:00.000Z";
|
|
194
|
+
|
|
195
|
+
it("keeps server order strictly earlier than the prepend bound", () => {
|
|
196
|
+
const mapped = mapWireMessages(olderPage, { beforeCreatedAt: bound });
|
|
197
|
+
const times = mapped.map((message) => Date.parse(message.createdAt));
|
|
198
|
+
expect(mapped.map((message) => message.id)).toEqual(["p1", "p2", "p3"]);
|
|
199
|
+
expect(times[0]).toBeLessThan(times[1]);
|
|
200
|
+
expect(times[1]).toBeLessThan(times[2]);
|
|
201
|
+
for (const time of times) {
|
|
202
|
+
expect(time).toBeLessThan(Date.parse(bound));
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("is deterministic across runs", () => {
|
|
207
|
+
const first = mapWireMessages(olderPage, { beforeCreatedAt: bound });
|
|
208
|
+
const second = mapWireMessages(olderPage, { beforeCreatedAt: bound });
|
|
209
|
+
expect(first.map((message) => message.createdAt)).toEqual(
|
|
210
|
+
second.map((message) => message.createdAt)
|
|
211
|
+
);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("interleaves synthesized values with neighboring real timestamps", () => {
|
|
215
|
+
const mapped = mapWireMessages([
|
|
216
|
+
{
|
|
217
|
+
id: "a",
|
|
218
|
+
role: "user",
|
|
219
|
+
content: "one",
|
|
220
|
+
displayAvailable: true,
|
|
221
|
+
timestamp: "2026-02-01T00:00:00.000Z",
|
|
222
|
+
},
|
|
223
|
+
{ id: "b", role: "assistant", content: "two", displayAvailable: true },
|
|
224
|
+
{ id: "c", role: "assistant", content: "three", displayAvailable: true },
|
|
225
|
+
{
|
|
226
|
+
id: "d",
|
|
227
|
+
role: "user",
|
|
228
|
+
content: "four",
|
|
229
|
+
displayAvailable: true,
|
|
230
|
+
timestamp: "2026-02-01T00:01:00.000Z",
|
|
231
|
+
},
|
|
232
|
+
{ id: "e", role: "assistant", content: "five", displayAvailable: true },
|
|
233
|
+
]);
|
|
234
|
+
const times = mapped.map((message) => Date.parse(message.createdAt));
|
|
235
|
+
expect(times).toEqual([...times].sort((x, y) => x - y));
|
|
236
|
+
expect(times[1]).toBeGreaterThan(times[0]);
|
|
237
|
+
expect(times[2]).toBeLessThan(times[3]);
|
|
238
|
+
// No older bound: a trailing run steps forward from the last real value.
|
|
239
|
+
expect(times[4]).toBeGreaterThan(times[3]);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("keeps a fully timestampless older page before the bound", () => {
|
|
243
|
+
const mapped = mapWireMessages(
|
|
244
|
+
[
|
|
245
|
+
{ id: "x", role: "user", content: "one", displayAvailable: true },
|
|
246
|
+
{ id: "y", role: "assistant", content: "two", displayAvailable: true },
|
|
247
|
+
],
|
|
248
|
+
{ beforeCreatedAt: bound }
|
|
249
|
+
);
|
|
250
|
+
expect(mapped.map((message) => message.createdAt)).toEqual([
|
|
251
|
+
"2026-01-31T23:59:59.998Z",
|
|
252
|
+
"2026-01-31T23:59:59.999Z",
|
|
253
|
+
]);
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
describe("mergeWireMessagesById", () => {
|
|
258
|
+
const existing: AgentWidgetMessage[] = [
|
|
259
|
+
{ id: "m2", role: "assistant", content: "old two", createdAt: "2026-02-01T00:00:02.000Z" },
|
|
260
|
+
{ id: "m3", role: "user", content: "three", createdAt: "2026-02-01T00:00:03.000Z" },
|
|
261
|
+
];
|
|
262
|
+
const incoming: AgentWidgetMessage[] = [
|
|
263
|
+
{ id: "m1", role: "user", content: "one", createdAt: "2026-02-01T00:00:01.000Z" },
|
|
264
|
+
{ id: "m2", role: "assistant", content: "new two", createdAt: "2026-02-01T00:00:02.000Z" },
|
|
265
|
+
];
|
|
266
|
+
|
|
267
|
+
it("dedupes by id with incoming winning and sorts once by createdAt", () => {
|
|
268
|
+
const merged = mergeWireMessagesById(existing, incoming);
|
|
269
|
+
expect(merged.map((message) => message.id)).toEqual(["m1", "m2", "m3"]);
|
|
270
|
+
expect(merged.find((message) => message.id === "m2")?.content).toBe("new two");
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("falls back to sequence then id when createdAt ties", () => {
|
|
274
|
+
const at = "2026-02-01T00:00:00.000Z";
|
|
275
|
+
const merged = mergeWireMessagesById(
|
|
276
|
+
[
|
|
277
|
+
{ id: "b", role: "user", content: "b", createdAt: at, sequence: 2 },
|
|
278
|
+
{ id: "a", role: "user", content: "a", createdAt: at, sequence: 1 },
|
|
279
|
+
],
|
|
280
|
+
[{ id: "c", role: "user", content: "c", createdAt: at, sequence: 1 }]
|
|
281
|
+
);
|
|
282
|
+
expect(merged.map((message) => message.id)).toEqual(["a", "c", "b"]);
|
|
283
|
+
});
|
|
284
|
+
});
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* History Wire Message Mapping
|
|
3
|
+
*
|
|
4
|
+
* Adapter from the Runtype `/v1/client/conversations` wire shape to widget
|
|
5
|
+
* messages. The display projection (`displayContent`) is the only visible
|
|
6
|
+
* authority: model-only content is never rendered and never re-enters the
|
|
7
|
+
* model channel (`llmContent`/`rawContent` are never fabricated here).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type {
|
|
11
|
+
AgentMessageMetadata,
|
|
12
|
+
AgentWidgetMessage,
|
|
13
|
+
AgentWidgetMessageRole,
|
|
14
|
+
ContentPart,
|
|
15
|
+
} from "../types";
|
|
16
|
+
import {
|
|
17
|
+
IMAGE_ONLY_MESSAGE_FALLBACK_TEXT,
|
|
18
|
+
createFilePart,
|
|
19
|
+
createImagePart,
|
|
20
|
+
createTextPart,
|
|
21
|
+
getDisplayText,
|
|
22
|
+
hasImages,
|
|
23
|
+
} from "./content";
|
|
24
|
+
|
|
25
|
+
/** One message as served by the client conversation history route. */
|
|
26
|
+
export type HistoryWireMessage = {
|
|
27
|
+
id: string;
|
|
28
|
+
role: string;
|
|
29
|
+
/** Model content: string (legacy render-safe fallback) or multi-modal parts. */
|
|
30
|
+
content?: string | unknown[];
|
|
31
|
+
/** Server-owned display projection. Absent and empty are distinct states. */
|
|
32
|
+
displayContent?: string;
|
|
33
|
+
/** False when no visible form is reconstructable (model content withheld). */
|
|
34
|
+
displayAvailable: boolean;
|
|
35
|
+
timestamp?: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** One row of the conversation list. */
|
|
39
|
+
export type HistoryWireConversationSummary = {
|
|
40
|
+
id: string;
|
|
41
|
+
title: string;
|
|
42
|
+
targetId: string | null;
|
|
43
|
+
flowId: string;
|
|
44
|
+
preview: string | null;
|
|
45
|
+
messageCount: number;
|
|
46
|
+
createdAt: string;
|
|
47
|
+
updatedAt: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export type MapWireMessagesOptions = {
|
|
51
|
+
/**
|
|
52
|
+
* Oldest `createdAt` already in the transcript. Present when mapping an older
|
|
53
|
+
* page that will be prepended: synthesized timestamps must land before it.
|
|
54
|
+
*/
|
|
55
|
+
beforeCreatedAt?: string;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** `agentMetadata` carrying the history marker; widened locally, not in types.ts. */
|
|
59
|
+
type HistoryAgentMetadata = AgentMessageMetadata & {
|
|
60
|
+
/** Server withheld this message's content and no display form exists. */
|
|
61
|
+
historyDisplayUnavailable?: true;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const WIDGET_ROLES: readonly string[] = ["user", "assistant", "system"];
|
|
65
|
+
|
|
66
|
+
/** Anchor for a page with no real timestamps and no older bound. */
|
|
67
|
+
const SYNTHETIC_EPOCH_MS = 0;
|
|
68
|
+
|
|
69
|
+
function parseMs(value: string | undefined): number | null {
|
|
70
|
+
if (typeof value !== "string" || value === "") return null;
|
|
71
|
+
const ms = Date.parse(value);
|
|
72
|
+
return Number.isNaN(ms) ? null : ms;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function optionalString(value: unknown): string | undefined {
|
|
76
|
+
return typeof value === "string" ? value : undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Known content parts only; anything else is dropped rather than surfaced. */
|
|
80
|
+
function toContentPart(part: unknown): ContentPart | null {
|
|
81
|
+
if (typeof part !== "object" || part === null) return null;
|
|
82
|
+
const raw = part as Record<string, unknown>;
|
|
83
|
+
switch (raw.type) {
|
|
84
|
+
case "text":
|
|
85
|
+
return typeof raw.text === "string" ? createTextPart(raw.text) : null;
|
|
86
|
+
case "image":
|
|
87
|
+
return typeof raw.image === "string"
|
|
88
|
+
? createImagePart(raw.image, {
|
|
89
|
+
mimeType: optionalString(raw.mimeType),
|
|
90
|
+
alt: optionalString(raw.alt),
|
|
91
|
+
})
|
|
92
|
+
: null;
|
|
93
|
+
case "file":
|
|
94
|
+
return typeof raw.data === "string" &&
|
|
95
|
+
typeof raw.mimeType === "string" &&
|
|
96
|
+
typeof raw.filename === "string"
|
|
97
|
+
? createFilePart(raw.data, raw.mimeType, raw.filename)
|
|
98
|
+
: null;
|
|
99
|
+
case "audio":
|
|
100
|
+
return typeof raw.audio === "string"
|
|
101
|
+
? {
|
|
102
|
+
type: "audio",
|
|
103
|
+
audio: raw.audio,
|
|
104
|
+
...(optionalString(raw.mimeType) !== undefined && {
|
|
105
|
+
mimeType: raw.mimeType as string,
|
|
106
|
+
}),
|
|
107
|
+
}
|
|
108
|
+
: null;
|
|
109
|
+
case "video":
|
|
110
|
+
return typeof raw.video === "string"
|
|
111
|
+
? {
|
|
112
|
+
type: "video",
|
|
113
|
+
video: raw.video,
|
|
114
|
+
...(optionalString(raw.mimeType) !== undefined && {
|
|
115
|
+
mimeType: raw.mimeType as string,
|
|
116
|
+
}),
|
|
117
|
+
}
|
|
118
|
+
: null;
|
|
119
|
+
default:
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Fill missing timestamps against the transcript boundary.
|
|
126
|
+
*
|
|
127
|
+
* A run of missing values counts back in 1 ms steps from its upper bound (the
|
|
128
|
+
* next real timestamp, the older-page bound, or the lower of the two), so every
|
|
129
|
+
* synthesized value stays strictly earlier than that bound and increases in
|
|
130
|
+
* server order. With no upper bound the run steps forward from the previous
|
|
131
|
+
* real timestamp. Deterministic: derived only from the inputs.
|
|
132
|
+
*/
|
|
133
|
+
function resolveCreatedAt(
|
|
134
|
+
wire: readonly HistoryWireMessage[],
|
|
135
|
+
beforeCreatedAt: string | undefined
|
|
136
|
+
): string[] {
|
|
137
|
+
const boundMs = parseMs(beforeCreatedAt);
|
|
138
|
+
const times = wire.map((message) => parseMs(message.timestamp));
|
|
139
|
+
const resolved: number[] = new Array<number>(wire.length);
|
|
140
|
+
|
|
141
|
+
let index = 0;
|
|
142
|
+
while (index < times.length) {
|
|
143
|
+
const known = times[index];
|
|
144
|
+
if (known !== null) {
|
|
145
|
+
resolved[index] = known;
|
|
146
|
+
index += 1;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
let end = index;
|
|
150
|
+
while (end + 1 < times.length && times[end + 1] === null) end += 1;
|
|
151
|
+
const runLength = end - index + 1;
|
|
152
|
+
const lower = index > 0 ? resolved[index - 1] : null;
|
|
153
|
+
const next = end + 1 < times.length ? times[end + 1] : null;
|
|
154
|
+
const upper =
|
|
155
|
+
next !== null && boundMs !== null
|
|
156
|
+
? Math.min(next, boundMs)
|
|
157
|
+
: (next ?? boundMs);
|
|
158
|
+
for (let offset = 0; offset < runLength; offset += 1) {
|
|
159
|
+
resolved[index + offset] =
|
|
160
|
+
upper !== null
|
|
161
|
+
? upper - (runLength - offset)
|
|
162
|
+
: lower !== null
|
|
163
|
+
? lower + offset + 1
|
|
164
|
+
: SYNTHETIC_EPOCH_MS + offset;
|
|
165
|
+
}
|
|
166
|
+
index = end + 1;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return resolved.map((ms) => new Date(ms).toISOString());
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Map wire history messages to widget messages.
|
|
174
|
+
*
|
|
175
|
+
* Display priority: `displayContent` (including an empty string) wins; a plain
|
|
176
|
+
* string model `content` is the legacy fallback; parts become `contentParts`.
|
|
177
|
+
*/
|
|
178
|
+
export function mapWireMessages(
|
|
179
|
+
wire: HistoryWireMessage[],
|
|
180
|
+
opts?: MapWireMessagesOptions
|
|
181
|
+
): AgentWidgetMessage[] {
|
|
182
|
+
const createdAtByIndex = resolveCreatedAt(wire, opts?.beforeCreatedAt);
|
|
183
|
+
const mapped: AgentWidgetMessage[] = [];
|
|
184
|
+
|
|
185
|
+
wire.forEach((message, index) => {
|
|
186
|
+
if (typeof message?.id !== "string" || message.id === "") return;
|
|
187
|
+
if (!WIDGET_ROLES.includes(message.role)) return;
|
|
188
|
+
const role = message.role as AgentWidgetMessageRole;
|
|
189
|
+
const createdAt = createdAtByIndex[index];
|
|
190
|
+
|
|
191
|
+
// No reconstructable visible form: render nothing, mark the gap.
|
|
192
|
+
if (message.displayAvailable === false) {
|
|
193
|
+
const agentMetadata: HistoryAgentMetadata = {
|
|
194
|
+
historyDisplayUnavailable: true,
|
|
195
|
+
};
|
|
196
|
+
mapped.push({ id: message.id, role, content: "", createdAt, agentMetadata });
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (typeof message.displayContent === "string") {
|
|
201
|
+
mapped.push({
|
|
202
|
+
id: message.id,
|
|
203
|
+
role,
|
|
204
|
+
content: message.displayContent,
|
|
205
|
+
createdAt,
|
|
206
|
+
});
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (typeof message.content === "string") {
|
|
211
|
+
mapped.push({ id: message.id, role, content: message.content, createdAt });
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (Array.isArray(message.content)) {
|
|
216
|
+
const parts = message.content
|
|
217
|
+
.map(toContentPart)
|
|
218
|
+
.filter((part): part is ContentPart => part !== null);
|
|
219
|
+
const text = getDisplayText(parts);
|
|
220
|
+
mapped.push({
|
|
221
|
+
id: message.id,
|
|
222
|
+
role,
|
|
223
|
+
content:
|
|
224
|
+
text || (hasImages(parts) ? IMAGE_ONLY_MESSAGE_FALLBACK_TEXT : ""),
|
|
225
|
+
createdAt,
|
|
226
|
+
...(parts.length > 0 && { contentParts: parts }),
|
|
227
|
+
});
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
mapped.push({ id: message.id, role, content: "", createdAt });
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
return mapped;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The visitor-visible projection when it diverges from the model channel that
|
|
239
|
+
* `/client/chat` stores (contract fact #15). `undefined` means the stored model
|
|
240
|
+
* content already IS the projection, so nothing extra needs sending.
|
|
241
|
+
*/
|
|
242
|
+
export function divergentDisplayProjection(
|
|
243
|
+
message: AgentWidgetMessage
|
|
244
|
+
): string | undefined {
|
|
245
|
+
const display = message.content;
|
|
246
|
+
if (typeof display !== "string" || display === "") return undefined;
|
|
247
|
+
const model =
|
|
248
|
+
message.contentParts ?? message.llmContent ?? message.rawContent ?? display;
|
|
249
|
+
if (typeof model === "string" && model === display) return undefined;
|
|
250
|
+
return display;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** True when the server withheld this history message's content. */
|
|
254
|
+
export function isHistoryDisplayUnavailable(
|
|
255
|
+
message: AgentWidgetMessage
|
|
256
|
+
): boolean {
|
|
257
|
+
const metadata = message.agentMetadata as HistoryAgentMetadata | undefined;
|
|
258
|
+
return metadata?.historyDisplayUnavailable === true;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Merge pages by message id (incoming wins) into one array sorted the way
|
|
263
|
+
* `sortMessages` sorts: `createdAt`, then `sequence`, then id.
|
|
264
|
+
*/
|
|
265
|
+
export function mergeWireMessagesById(
|
|
266
|
+
existing: AgentWidgetMessage[],
|
|
267
|
+
incoming: AgentWidgetMessage[]
|
|
268
|
+
): AgentWidgetMessage[] {
|
|
269
|
+
const byId = new Map<string, AgentWidgetMessage>();
|
|
270
|
+
for (const message of existing) byId.set(message.id, message);
|
|
271
|
+
for (const message of incoming) byId.set(message.id, message);
|
|
272
|
+
|
|
273
|
+
return [...byId.values()].sort((a, b) => {
|
|
274
|
+
const timeA = new Date(a.createdAt).getTime();
|
|
275
|
+
const timeB = new Date(b.createdAt).getTime();
|
|
276
|
+
if (!Number.isNaN(timeA) && !Number.isNaN(timeB) && timeA !== timeB) {
|
|
277
|
+
return timeA - timeB;
|
|
278
|
+
}
|
|
279
|
+
const seqA = a.sequence ?? 0;
|
|
280
|
+
const seqB = b.sequence ?? 0;
|
|
281
|
+
if (seqA !== seqB) return seqA - seqB;
|
|
282
|
+
return a.id.localeCompare(b.id);
|
|
283
|
+
});
|
|
284
|
+
}
|
package/src/utils/icons.ts
CHANGED
|
@@ -66,6 +66,7 @@ import {
|
|
|
66
66
|
EllipsisVertical,
|
|
67
67
|
Menu,
|
|
68
68
|
House,
|
|
69
|
+
PanelLeft,
|
|
69
70
|
// ---------- Actions ----------
|
|
70
71
|
Plus,
|
|
71
72
|
Minus,
|
|
@@ -220,6 +221,8 @@ const LUCIDE_ICONS = {
|
|
|
220
221
|
"ellipsis-vertical": EllipsisVertical,
|
|
221
222
|
"menu": Menu,
|
|
222
223
|
"house": House,
|
|
224
|
+
// Sidebar glyph shared with the history rail's collapse controls.
|
|
225
|
+
"panel-left": PanelLeft,
|
|
223
226
|
// Actions
|
|
224
227
|
"plus": Plus,
|
|
225
228
|
"minus": Minus,
|