@runtypelabs/persona 4.8.0 → 4.10.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/README.md +14 -1
- package/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/{types-BsZtXPKK.d.cts → types-4ROVJ1gA.d.cts} +42 -0
- package/dist/animations/{types-BsZtXPKK.d.ts → types-4ROVJ1gA.d.ts} +42 -0
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/chunk-IO5VVUKP.js +3 -0
- package/dist/chunk-IPVK3KOM.js +1 -0
- package/dist/chunk-UPO4GUFC.js +1 -0
- package/dist/codegen.cjs +6 -6
- package/dist/codegen.js +8 -8
- package/dist/context-mentions-7S5KVUTG.js +169 -0
- package/dist/context-mentions-inline-TTCN7ZM2.js +4 -0
- package/dist/context-mentions-inline.cjs +4 -0
- package/dist/context-mentions-inline.d.cts +203 -0
- package/dist/context-mentions-inline.d.ts +203 -0
- package/dist/context-mentions-inline.js +4 -0
- package/dist/context-mentions.cjs +295 -0
- package/dist/context-mentions.d.cts +7025 -0
- package/dist/context-mentions.d.ts +7025 -0
- package/dist/context-mentions.js +295 -0
- package/dist/index.cjs +72 -64
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +634 -3
- package/dist/index.d.ts +634 -3
- package/dist/index.global.js +59 -51
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +64 -56
- package/dist/index.js.map +1 -1
- package/dist/launcher.global.js +2 -2
- package/dist/launcher.global.js.map +1 -1
- package/dist/plugin-kit.cjs +1 -1
- package/dist/plugin-kit.d.cts +17 -0
- package/dist/plugin-kit.d.ts +17 -0
- package/dist/plugin-kit.js +1 -1
- package/dist/smart-dom-reader.cjs +17 -16
- package/dist/smart-dom-reader.d.cts +507 -1
- package/dist/smart-dom-reader.d.ts +507 -1
- package/dist/smart-dom-reader.js +17 -16
- package/dist/theme-editor-preview.cjs +236 -57
- package/dist/theme-editor-preview.d.cts +485 -1
- package/dist/theme-editor-preview.d.ts +485 -1
- package/dist/theme-editor-preview.js +53 -47
- package/dist/theme-editor.cjs +7 -7
- package/dist/theme-editor.d.cts +473 -0
- package/dist/theme-editor.d.ts +473 -0
- package/dist/theme-editor.js +5 -5
- package/dist/theme-reference.cjs +1 -1
- package/dist/theme-reference.d.cts +2 -0
- package/dist/theme-reference.d.ts +2 -0
- package/dist/widget.css +1 -1
- package/package.json +15 -3
- package/src/client.test.ts +69 -0
- package/src/client.ts +65 -51
- package/src/components/artifact-pane.test.ts +47 -0
- package/src/components/artifact-pane.ts +25 -2
- package/src/components/composer-parts.ts +3 -12
- package/src/components/context-mention-button.test.ts +70 -0
- package/src/components/context-mention-button.ts +82 -0
- package/src/components/context-mention-chip.ts +134 -0
- package/src/components/context-mention-menu.test.ts +508 -0
- package/src/components/context-mention-menu.ts +0 -0
- package/src/components/message-bubble.test.ts +175 -0
- package/src/components/message-bubble.ts +177 -19
- package/src/components/panel.ts +7 -10
- package/src/context-mentions-bundle.test.ts +163 -0
- package/src/context-mentions-entry.ts +185 -0
- package/src/context-mentions-inline-entry.test.ts +136 -0
- package/src/context-mentions-inline-entry.ts +226 -0
- package/src/context-mentions-inline-loader.test.ts +30 -0
- package/src/context-mentions-inline-loader.ts +36 -0
- package/src/context-mentions-inline.ts +15 -0
- package/src/context-mentions-loader.ts +32 -0
- package/src/context-mentions.ts +16 -0
- package/src/index-core.ts +27 -0
- package/src/index-global.ts +51 -0
- package/src/markdown-parsers-loader.ts +35 -44
- package/src/plugin-kit.test.ts +40 -0
- package/src/plugin-kit.ts +39 -5
- package/src/runtime/init-update-reset.test.ts +81 -0
- package/src/runtime/init.test.ts +62 -0
- package/src/runtime/init.ts +7 -14
- package/src/session.mentions.test.ts +175 -0
- package/src/session.test.ts +52 -4
- package/src/session.ts +121 -5
- package/src/smart-dom-reader.test.ts +129 -2
- package/src/smart-dom-reader.ts +127 -1
- package/src/styles/context-mention-menu-css.ts +176 -0
- package/src/styles/widget.css +243 -126
- package/src/theme-editor/sections.ts +3 -3
- package/src/types/theme.ts +2 -0
- package/src/types.ts +542 -0
- package/src/ui.artifact-pane-gating.test.ts +11 -1
- package/src/ui.attachments-drop.test.ts +90 -0
- package/src/ui.header-update-stability.test.ts +149 -0
- package/src/ui.launcher-update-merge.test.ts +83 -0
- package/src/ui.mention-submit.test.ts +235 -0
- package/src/ui.send-button-stream-update.test.ts +69 -0
- package/src/ui.ts +379 -84
- package/src/utils/chunk-loader.test.ts +97 -0
- package/src/utils/chunk-loader.ts +88 -0
- package/src/utils/composer-contenteditable.test.ts +507 -0
- package/src/utils/composer-contenteditable.ts +626 -0
- package/src/utils/composer-document.test.ts +280 -0
- package/src/utils/composer-document.ts +293 -0
- package/src/utils/composer-history.test.ts +35 -7
- package/src/utils/composer-history.ts +30 -20
- package/src/utils/composer-input.ts +159 -0
- package/src/utils/config-merge.test.ts +131 -0
- package/src/utils/config-merge.ts +61 -0
- package/src/utils/context-mention-controller.test.ts +1215 -0
- package/src/utils/context-mention-controller.ts +1186 -0
- package/src/utils/context-mention-manager.test.ts +422 -0
- package/src/utils/context-mention-manager.ts +410 -0
- package/src/utils/context-mention-orchestrator.test.ts +538 -0
- package/src/utils/context-mention-orchestrator.ts +348 -0
- package/src/utils/live-region.test.ts +108 -0
- package/src/utils/live-region.ts +94 -0
- package/src/utils/mention-channels.ts +63 -0
- package/src/utils/mention-llm-format.test.ts +91 -0
- package/src/utils/mention-llm-format.ts +79 -0
- package/src/utils/mention-matcher.test.ts +86 -0
- package/src/utils/mention-matcher.ts +221 -0
- package/src/utils/mention-token.ts +72 -0
- package/src/utils/mention-trigger.test.ts +155 -0
- package/src/utils/mention-trigger.ts +156 -0
- package/src/utils/theme.test.ts +54 -4
- package/src/utils/theme.ts +6 -3
- package/src/utils/tokens.ts +27 -11
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { AgentWidgetSession } from "./session";
|
|
3
|
+
import type {
|
|
4
|
+
AgentWidgetMessage,
|
|
5
|
+
AgentWidgetRequestPayload,
|
|
6
|
+
ContentPart,
|
|
7
|
+
} from "./types";
|
|
8
|
+
import type { MentionSubmitBundle } from "./context-mentions-entry";
|
|
9
|
+
|
|
10
|
+
/** A closed, empty SSE stream so `dispatch` completes with no events. */
|
|
11
|
+
function closedStream(): ReadableStream<Uint8Array> {
|
|
12
|
+
return new ReadableStream({ start: (c) => c.close() });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Regression coverage for the mention/command submit path:
|
|
17
|
+
* - an empty-text, chip-only submit must NOT render the "[Image]" fallback
|
|
18
|
+
* (that fallback is for image-only turns);
|
|
19
|
+
* - a `command: "server"` mention's `resolve().context` must ride to the
|
|
20
|
+
* backend under request `context.mentions` (the c1 dispatch channel).
|
|
21
|
+
*/
|
|
22
|
+
describe("AgentWidgetSession — mention/command submit", () => {
|
|
23
|
+
function makeSession(captured: { payload?: AgentWidgetRequestPayload }) {
|
|
24
|
+
let messages: AgentWidgetMessage[] = [];
|
|
25
|
+
let status = "idle";
|
|
26
|
+
let streaming = false;
|
|
27
|
+
const session = new AgentWidgetSession(
|
|
28
|
+
{
|
|
29
|
+
apiUrl: "http://localhost:8000",
|
|
30
|
+
customFetch: async (_url, _init, payload) => {
|
|
31
|
+
captured.payload = payload;
|
|
32
|
+
return { ok: true, body: closedStream() } as unknown as Response;
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
onMessagesChanged: (m) => {
|
|
37
|
+
messages = m;
|
|
38
|
+
},
|
|
39
|
+
onStatusChanged: (s) => {
|
|
40
|
+
status = s;
|
|
41
|
+
},
|
|
42
|
+
onStreamingChanged: (s) => {
|
|
43
|
+
streaming = s;
|
|
44
|
+
},
|
|
45
|
+
onError: () => {},
|
|
46
|
+
}
|
|
47
|
+
);
|
|
48
|
+
return {
|
|
49
|
+
session,
|
|
50
|
+
user: () => messages.find((m) => m.role === "user"),
|
|
51
|
+
status: () => status,
|
|
52
|
+
streaming: () => streaming,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
it("renders no [Image] fallback for a command-only submit and forwards context.mentions", async () => {
|
|
57
|
+
const captured: { payload?: AgentWidgetRequestPayload } = {};
|
|
58
|
+
const { session, user } = makeSession(captured);
|
|
59
|
+
|
|
60
|
+
// A server skill: `/lookup 1042` resolved to structured context, namespaced
|
|
61
|
+
// `{ [sourceId]: { [itemId]: context } }` exactly as the manager builds it.
|
|
62
|
+
const context = { commands: { lookup: { intent: "lookup-order", orderId: "1042" } } };
|
|
63
|
+
const refs = [{ sourceId: "commands", itemId: "lookup", label: "lookup" }];
|
|
64
|
+
const bundle: MentionSubmitBundle = {
|
|
65
|
+
blocks: [],
|
|
66
|
+
contentParts: [],
|
|
67
|
+
context,
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
await session.sendMessage("", {
|
|
71
|
+
mentions: {
|
|
72
|
+
refs,
|
|
73
|
+
finalize: async () => bundle,
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const msg = user();
|
|
78
|
+
expect(msg).toBeDefined();
|
|
79
|
+
// The bug: an empty-text, mention-only submit used to display "[Image]".
|
|
80
|
+
expect(msg!.content).toBe("");
|
|
81
|
+
expect(msg!.content).not.toBe("[Image]");
|
|
82
|
+
// The command chip echoes immediately as a ref.
|
|
83
|
+
expect(msg!.contextMentions).toEqual(refs);
|
|
84
|
+
// Structured server-command data is merged onto the message…
|
|
85
|
+
expect(msg!.mentionContext).toEqual(context);
|
|
86
|
+
// …and rides to the backend under request `context.mentions`.
|
|
87
|
+
expect(captured.payload?.context).toMatchObject({ mentions: context });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("merges resolved @-mention llmContent into the dispatched snapshot", async () => {
|
|
91
|
+
const captured: { payload?: AgentWidgetRequestPayload } = {};
|
|
92
|
+
const { session } = makeSession(captured);
|
|
93
|
+
|
|
94
|
+
await session.sendMessage("summarize this", {
|
|
95
|
+
mentions: {
|
|
96
|
+
refs: [{ sourceId: "files", itemId: "app", label: "App.tsx" }],
|
|
97
|
+
finalize: async (): Promise<MentionSubmitBundle> => ({
|
|
98
|
+
blocks: ["```App.tsx\nFILE BODY\n```"],
|
|
99
|
+
contentParts: [],
|
|
100
|
+
context: {},
|
|
101
|
+
}),
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const dispatched = (captured.payload?.messages ?? []).find(
|
|
106
|
+
(m) => m.role === "user"
|
|
107
|
+
);
|
|
108
|
+
// The client collapses `llmContent` into the payload `content` field via the
|
|
109
|
+
// content-priority chain, so the model must see the resolved file body
|
|
110
|
+
// appended to the typed prose — not just the raw "summarize this".
|
|
111
|
+
const sent = dispatched?.content;
|
|
112
|
+
expect(typeof sent).toBe("string");
|
|
113
|
+
expect(sent as string).toContain("FILE BODY");
|
|
114
|
+
expect(sent as string).toContain("summarize this");
|
|
115
|
+
// Mention blocks lead; the typed prose lands last (joined by a blank line).
|
|
116
|
+
expect(sent as string).toBe("```App.tsx\nFILE BODY\n```\n\nsummarize this");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("still renders the [Image] fallback for an image-only submit", async () => {
|
|
120
|
+
const captured: { payload?: AgentWidgetRequestPayload } = {};
|
|
121
|
+
const { session, user } = makeSession(captured);
|
|
122
|
+
|
|
123
|
+
const contentParts: ContentPart[] = [
|
|
124
|
+
{ type: "image", image: "data:image/png;base64,abc123", mimeType: "image/png" },
|
|
125
|
+
];
|
|
126
|
+
await session.sendMessage("", { contentParts });
|
|
127
|
+
|
|
128
|
+
expect(user()!.content).toBe("[Image]");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("cancel() during a pending mention finalize aborts the turn without dispatching", async () => {
|
|
132
|
+
const captured: { payload?: AgentWidgetRequestPayload } = {};
|
|
133
|
+
const { session, status, streaming } = makeSession(captured);
|
|
134
|
+
|
|
135
|
+
let releaseFinalize!: (bundle: MentionSubmitBundle) => void;
|
|
136
|
+
const pending = new Promise<MentionSubmitBundle>((resolve) => {
|
|
137
|
+
releaseFinalize = resolve;
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const sent = session.sendMessage("summarize this", {
|
|
141
|
+
mentions: {
|
|
142
|
+
refs: [{ sourceId: "files", itemId: "app", label: "App.tsx" }],
|
|
143
|
+
finalize: () => pending,
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// Stop mid-resolution, then let finalize settle: dispatch must not fire.
|
|
148
|
+
session.cancel();
|
|
149
|
+
releaseFinalize({ blocks: ["```App.tsx\nBODY\n```"], contentParts: [], context: {} });
|
|
150
|
+
await sent;
|
|
151
|
+
|
|
152
|
+
expect(captured.payload).toBeUndefined();
|
|
153
|
+
expect(status()).toBe("idle");
|
|
154
|
+
expect(streaming()).toBe(false);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("dispatches a normal mention submit once finalize resolves", async () => {
|
|
158
|
+
const captured: { payload?: AgentWidgetRequestPayload } = {};
|
|
159
|
+
const { session, streaming } = makeSession(captured);
|
|
160
|
+
|
|
161
|
+
await session.sendMessage("summarize this", {
|
|
162
|
+
mentions: {
|
|
163
|
+
refs: [{ sourceId: "files", itemId: "app", label: "App.tsx" }],
|
|
164
|
+
finalize: async (): Promise<MentionSubmitBundle> => ({
|
|
165
|
+
blocks: ["```App.tsx\nBODY\n```"],
|
|
166
|
+
contentParts: [],
|
|
167
|
+
context: {},
|
|
168
|
+
}),
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
expect(captured.payload).toBeDefined();
|
|
173
|
+
expect(streaming()).toBe(false);
|
|
174
|
+
});
|
|
175
|
+
});
|
package/src/session.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
|
2
2
|
import { AgentWidgetSession, AgentWidgetSessionStatus } from './session';
|
|
3
|
-
import { AgentWidgetMessage } from './types';
|
|
3
|
+
import { AgentWidgetMessage, AgentWidgetContentSegment } from './types';
|
|
4
4
|
|
|
5
5
|
describe('AgentWidgetSession - Message Injection', () => {
|
|
6
6
|
let session: AgentWidgetSession;
|
|
@@ -369,6 +369,51 @@ describe('AgentWidgetSession - Message Injection', () => {
|
|
|
369
369
|
});
|
|
370
370
|
});
|
|
371
371
|
|
|
372
|
+
describe('AgentWidgetSession - inline contentSegments', () => {
|
|
373
|
+
const originalFetch = global.fetch;
|
|
374
|
+
afterEach(() => {
|
|
375
|
+
global.fetch = originalFetch;
|
|
376
|
+
vi.restoreAllMocks();
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
const makeHangingSession = () => {
|
|
380
|
+
// A never-settling fetch keeps the turn in-flight so the appended user
|
|
381
|
+
// message can be inspected without a dispatch-error bubble replacing it.
|
|
382
|
+
global.fetch = vi.fn().mockImplementation(() => new Promise(() => {}));
|
|
383
|
+
return new AgentWidgetSession(
|
|
384
|
+
{ apiUrl: 'http://example.invalid/chat' },
|
|
385
|
+
{
|
|
386
|
+
onMessagesChanged: () => {},
|
|
387
|
+
onStatusChanged: () => {},
|
|
388
|
+
onStreamingChanged: () => {},
|
|
389
|
+
}
|
|
390
|
+
);
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
it('stores ordered contentSegments on the sent user message', () => {
|
|
394
|
+
const session = makeHangingSession();
|
|
395
|
+
const segments: AgentWidgetContentSegment[] = [
|
|
396
|
+
{ kind: 'text', text: 'Check ' },
|
|
397
|
+
{ kind: 'mention', ref: { sourceId: 'files', itemId: 'app', label: 'App.tsx' } },
|
|
398
|
+
{ kind: 'text', text: ' please' },
|
|
399
|
+
];
|
|
400
|
+
void session.sendMessage('Check @App.tsx please', {
|
|
401
|
+
contentSegments: segments,
|
|
402
|
+
});
|
|
403
|
+
const userMsg = session.getMessages().find((m) => m.role === 'user');
|
|
404
|
+
// Segments must land on the stored (sequence-normalized) copy, not just the
|
|
405
|
+
// orphaned input literal — this is the fix for the dead inline-token branch.
|
|
406
|
+
expect(userMsg?.contentSegments).toEqual(segments);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it('omits contentSegments for a plain message', () => {
|
|
410
|
+
const session = makeHangingSession();
|
|
411
|
+
void session.sendMessage('plain message');
|
|
412
|
+
const userMsg = session.getMessages().find((m) => m.role === 'user');
|
|
413
|
+
expect(userMsg?.contentSegments).toBeUndefined();
|
|
414
|
+
});
|
|
415
|
+
});
|
|
416
|
+
|
|
372
417
|
describe('AgentWidgetSession - cancel()', () => {
|
|
373
418
|
const originalFetch = global.fetch;
|
|
374
419
|
|
|
@@ -404,9 +449,12 @@ describe('AgentWidgetSession - cancel()', () => {
|
|
|
404
449
|
|
|
405
450
|
// Kick off the dispatch but don't await: we want it in-flight when we cancel.
|
|
406
451
|
const dispatchPromise = session.sendMessage('Hello');
|
|
407
|
-
//
|
|
408
|
-
|
|
409
|
-
|
|
452
|
+
// Drain microtasks until the session has set up the AbortController and
|
|
453
|
+
// called fetch (payload building is async, so the exact tick count is not a
|
|
454
|
+
// contract — wait for the observable effect instead).
|
|
455
|
+
for (let i = 0; i < 20 && capturedSignal === null; i++) {
|
|
456
|
+
await Promise.resolve();
|
|
457
|
+
}
|
|
410
458
|
|
|
411
459
|
expect(streaming).toBe(true);
|
|
412
460
|
expect(session.isStreaming()).toBe(true);
|
package/src/session.ts
CHANGED
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
generateUserMessageId,
|
|
28
28
|
generateAssistantMessageId
|
|
29
29
|
} from "./utils/message-id";
|
|
30
|
-
import { IMAGE_ONLY_MESSAGE_FALLBACK_TEXT } from "./utils/content";
|
|
30
|
+
import { IMAGE_ONLY_MESSAGE_FALLBACK_TEXT, createTextPart, hasImages } from "./utils/content";
|
|
31
31
|
import {
|
|
32
32
|
buildArtifactRefRawContent,
|
|
33
33
|
resolveArtifactDisplayMode
|
|
@@ -37,8 +37,11 @@ import type {
|
|
|
37
37
|
VoiceStatus,
|
|
38
38
|
VoiceConfig,
|
|
39
39
|
ReadAloudState,
|
|
40
|
-
SpeechEngine
|
|
40
|
+
SpeechEngine,
|
|
41
|
+
AgentWidgetContentSegment,
|
|
42
|
+
AgentWidgetContextMentionRef
|
|
41
43
|
} from "./types";
|
|
44
|
+
import type { MentionSubmitBundle } from "./utils/context-mention-manager";
|
|
42
45
|
import {
|
|
43
46
|
createVoiceProvider,
|
|
44
47
|
isVoiceSupported,
|
|
@@ -1129,17 +1132,92 @@ export class AgentWidgetSession {
|
|
|
1129
1132
|
});
|
|
1130
1133
|
}
|
|
1131
1134
|
|
|
1135
|
+
/**
|
|
1136
|
+
* Resolve the gathered mentions and merge them into the user message's
|
|
1137
|
+
* model-visible content. Default path is `llmAppend` → `llmContent` (or a text
|
|
1138
|
+
* `contentPart` when parts are present, since `contentParts` wins the priority
|
|
1139
|
+
* chain); the opt-in structured `context` lands on `mentionContext` for the
|
|
1140
|
+
* client to namespace into the request `context`. Failures were already
|
|
1141
|
+
* dropped by `finalize()`.
|
|
1142
|
+
*/
|
|
1143
|
+
private async applyMentionBundle(
|
|
1144
|
+
userMessage: AgentWidgetMessage,
|
|
1145
|
+
typedText: string,
|
|
1146
|
+
finalize: () => Promise<MentionSubmitBundle>
|
|
1147
|
+
): Promise<void> {
|
|
1148
|
+
let bundle: MentionSubmitBundle;
|
|
1149
|
+
try {
|
|
1150
|
+
bundle = await finalize();
|
|
1151
|
+
} catch {
|
|
1152
|
+
return; // resolution failed entirely; send the typed text as-is
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
// Blocks arrive pre-formatted from the mention layer (fenced/document/custom
|
|
1156
|
+
// per `contextMentions.llmFormat`); the session just joins them and appends
|
|
1157
|
+
// the typed prose last.
|
|
1158
|
+
const block = bundle.blocks.join("\n\n");
|
|
1159
|
+
const llmText = [block, typedText].filter(Boolean).join("\n\n");
|
|
1160
|
+
|
|
1161
|
+
const hasAttachments =
|
|
1162
|
+
Array.isArray(userMessage.contentParts) &&
|
|
1163
|
+
userMessage.contentParts.length > 0;
|
|
1164
|
+
const hasMentionParts = bundle.contentParts.length > 0;
|
|
1165
|
+
|
|
1166
|
+
if (hasAttachments) {
|
|
1167
|
+
// The typed text already rides as a content part; add the block + mention
|
|
1168
|
+
// parts ahead of the existing attachment parts (mentions first).
|
|
1169
|
+
const parts: ContentPart[] = [];
|
|
1170
|
+
if (block) parts.push(createTextPart(block));
|
|
1171
|
+
parts.push(...bundle.contentParts);
|
|
1172
|
+
parts.push(...userMessage.contentParts!);
|
|
1173
|
+
userMessage.contentParts = parts;
|
|
1174
|
+
} else if (hasMentionParts) {
|
|
1175
|
+
const parts: ContentPart[] = [];
|
|
1176
|
+
if (llmText) parts.push(createTextPart(llmText));
|
|
1177
|
+
parts.push(...bundle.contentParts);
|
|
1178
|
+
userMessage.contentParts = parts;
|
|
1179
|
+
} else if (block) {
|
|
1180
|
+
userMessage.llmContent = llmText;
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
if (Object.keys(bundle.context).length > 0) {
|
|
1184
|
+
userMessage.mentionContext = bundle.context;
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1132
1188
|
public async sendMessage(
|
|
1133
1189
|
rawInput: string,
|
|
1134
1190
|
options?: {
|
|
1135
1191
|
viaVoice?: boolean;
|
|
1136
1192
|
/** Multi-modal content parts (e.g., images) to include with the message */
|
|
1137
1193
|
contentParts?: ContentPart[];
|
|
1194
|
+
/**
|
|
1195
|
+
* Context mentions gathered from the composer. `refs` echo immediately as
|
|
1196
|
+
* chips; `finalize()` resolves any pending/submit sources just before
|
|
1197
|
+
* dispatch (after the instant echo) and the bundle is merged into this
|
|
1198
|
+
* message's model-visible content.
|
|
1199
|
+
*/
|
|
1200
|
+
mentions?: {
|
|
1201
|
+
refs: AgentWidgetContextMentionRef[];
|
|
1202
|
+
finalize: () => Promise<MentionSubmitBundle>;
|
|
1203
|
+
};
|
|
1204
|
+
/**
|
|
1205
|
+
* Ordered prose + mention segments for inline-mode display (`display:
|
|
1206
|
+
* "inline"`). Stored on the user message so its bubble re-renders `@tokens`
|
|
1207
|
+
* in place (and suppresses the chip row). Display/transcript only — the
|
|
1208
|
+
* model still sees resolved bodies via the `mentions` bundle above.
|
|
1209
|
+
*/
|
|
1210
|
+
contentSegments?: AgentWidgetContentSegment[];
|
|
1138
1211
|
}
|
|
1139
1212
|
) {
|
|
1140
1213
|
const input = rawInput.trim();
|
|
1141
|
-
// Allow sending if there's text OR attachments
|
|
1142
|
-
if (
|
|
1214
|
+
// Allow sending if there's text OR attachments OR mentions
|
|
1215
|
+
if (
|
|
1216
|
+
!input &&
|
|
1217
|
+
(!options?.contentParts || options.contentParts.length === 0) &&
|
|
1218
|
+
(!options?.mentions || options.mentions.refs.length === 0)
|
|
1219
|
+
)
|
|
1220
|
+
return;
|
|
1143
1221
|
|
|
1144
1222
|
this.stopSpeaking();
|
|
1145
1223
|
this.abortController?.abort();
|
|
@@ -1160,25 +1238,63 @@ export class AgentWidgetSession {
|
|
|
1160
1238
|
// the proxy path auto-generates a different id than `assistantMessageId`.
|
|
1161
1239
|
this.activeAssistantMessageId = null;
|
|
1162
1240
|
|
|
1241
|
+
// Fallback display text ONLY when the sole content is image attachments.
|
|
1242
|
+
// A mention/command-only submit (empty text + a chip) must NOT read as
|
|
1243
|
+
// "[Image]"; it renders its chips with empty text instead.
|
|
1244
|
+
const imageOnlyFallback =
|
|
1245
|
+
options?.contentParts && hasImages(options.contentParts)
|
|
1246
|
+
? IMAGE_ONLY_MESSAGE_FALLBACK_TEXT
|
|
1247
|
+
: "";
|
|
1248
|
+
|
|
1163
1249
|
const userMessage: AgentWidgetMessage = {
|
|
1164
1250
|
id: userMessageId,
|
|
1165
1251
|
role: "user",
|
|
1166
|
-
content: input ||
|
|
1252
|
+
content: input || imageOnlyFallback, // Display text (fallback if only images)
|
|
1167
1253
|
createdAt: new Date().toISOString(),
|
|
1168
1254
|
sequence: this.nextSequence(),
|
|
1169
1255
|
viaVoice: options?.viaVoice || false,
|
|
1170
1256
|
// Include contentParts if provided (for multi-modal messages)
|
|
1171
1257
|
...(options?.contentParts && options.contentParts.length > 0 && {
|
|
1172
1258
|
contentParts: options.contentParts
|
|
1259
|
+
}),
|
|
1260
|
+
// Echo mention chips immediately (refs only; payloads merged below).
|
|
1261
|
+
...(options?.mentions && options.mentions.refs.length > 0 && {
|
|
1262
|
+
contextMentions: options.mentions.refs
|
|
1263
|
+
}),
|
|
1264
|
+
// Inline mode: ordered display segments for in-prose `@token` rendering.
|
|
1265
|
+
...(options?.contentSegments && options.contentSegments.length > 0 && {
|
|
1266
|
+
contentSegments: options.contentSegments
|
|
1173
1267
|
})
|
|
1174
1268
|
};
|
|
1175
1269
|
|
|
1176
1270
|
this.appendMessage(userMessage);
|
|
1177
1271
|
this.setStreaming(true);
|
|
1178
1272
|
|
|
1273
|
+
// Assign the fresh controller BEFORE the mention await so cancel() (or a
|
|
1274
|
+
// superseding sendMessage) during finalize() aborts THIS turn, not a stale
|
|
1275
|
+
// prior controller.
|
|
1179
1276
|
const controller = new AbortController();
|
|
1180
1277
|
this.abortController = controller;
|
|
1181
1278
|
|
|
1279
|
+
// Resolve + merge mentions AFTER the instant echo but BEFORE dispatch, so
|
|
1280
|
+
// the model sees the context while the user's bubble already rendered.
|
|
1281
|
+
// `appendMessage` stores a sequence-normalized COPY (see `ensureSequence`),
|
|
1282
|
+
// so mutate THAT live reference — not the orphaned `userMessage` literal —
|
|
1283
|
+
// or the merged `llmContent`/`contentParts`/`mentionContext` never reach the
|
|
1284
|
+
// dispatch snapshot below. Re-emit so any merged parts also reach the UI.
|
|
1285
|
+
if (options?.mentions) {
|
|
1286
|
+
const stored =
|
|
1287
|
+
this.messages.find((m) => m.id === userMessageId) ?? userMessage;
|
|
1288
|
+
await this.applyMentionBundle(stored, input, options.mentions.finalize);
|
|
1289
|
+
// A cancel() or new sendMessage during finalize aborted this controller
|
|
1290
|
+
// (and replaced/nulled the shared ref). Bail without dispatching and
|
|
1291
|
+
// leave whatever idle/streaming state that caller already set.
|
|
1292
|
+
if (controller.signal.aborted || this.abortController !== controller) {
|
|
1293
|
+
return;
|
|
1294
|
+
}
|
|
1295
|
+
this.callbacks.onMessagesChanged([...this.messages]);
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1182
1298
|
const snapshot = [...this.messages];
|
|
1183
1299
|
|
|
1184
1300
|
try {
|
|
@@ -4,10 +4,11 @@
|
|
|
4
4
|
// vendored library under jsdom. The pure-mapper correctness guarantee lives in
|
|
5
5
|
// utils/smart-dom-adapter.test.ts (no DOM, no library); this test confirms the
|
|
6
6
|
// vendored runtime loads and the provider wires up against a real document.
|
|
7
|
-
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
7
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
8
8
|
import {
|
|
9
9
|
collectSmartDomContext,
|
|
10
|
-
createSmartDomReaderContextProvider
|
|
10
|
+
createSmartDomReaderContextProvider,
|
|
11
|
+
createSmartDomMentionSource
|
|
11
12
|
} from "./smart-dom-reader";
|
|
12
13
|
|
|
13
14
|
// jsdom implements no layout, so getBoundingClientRect()/offsetParent report the
|
|
@@ -116,6 +117,66 @@ describe("smart-dom-reader entry (jsdom)", () => {
|
|
|
116
117
|
expect(texts).toContain("Scoped shadow action");
|
|
117
118
|
});
|
|
118
119
|
|
|
120
|
+
it("mention source surfaces page elements as items and resolves text at submit", async () => {
|
|
121
|
+
document.body.innerHTML = `<main><button id="go">Continue to checkout</button></main>`;
|
|
122
|
+
const source = createSmartDomMentionSource({ label: "Page", ...JSDOM_OPTS });
|
|
123
|
+
expect(source.id).toBe("page");
|
|
124
|
+
expect(source.resolveOn).toBe("submit");
|
|
125
|
+
|
|
126
|
+
const items = await source.search("", { messages: [], config: {} as never, signal: new AbortController().signal });
|
|
127
|
+
const go = items.find((i) => i.label.includes("Continue"));
|
|
128
|
+
expect(go).toBeTruthy();
|
|
129
|
+
expect(go!.id).toMatch(/#go|go/); // selector is the item id
|
|
130
|
+
|
|
131
|
+
// Filtering narrows the snapshot client-side.
|
|
132
|
+
const filtered = await source.search("checkout", { messages: [], config: {} as never, signal: new AbortController().signal });
|
|
133
|
+
expect(filtered.some((i) => i.label.includes("Continue"))).toBe(true);
|
|
134
|
+
|
|
135
|
+
// resolve() reads the live element text at submit.
|
|
136
|
+
const payload = await source.resolve(go!, {
|
|
137
|
+
messages: [],
|
|
138
|
+
config: {} as never,
|
|
139
|
+
composerText: "",
|
|
140
|
+
args: "",
|
|
141
|
+
signal: new AbortController().signal,
|
|
142
|
+
});
|
|
143
|
+
expect(payload.llmAppend).toContain("Continue to checkout");
|
|
144
|
+
expect(payload.context).toMatchObject({ selector: go!.id });
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("applies mapItem to reshape surfaced items without breaking resolve", async () => {
|
|
148
|
+
document.body.innerHTML = `<main><button id="go">Continue to checkout</button></main>`;
|
|
149
|
+
const source = createSmartDomMentionSource({
|
|
150
|
+
label: "Page",
|
|
151
|
+
...JSDOM_OPTS,
|
|
152
|
+
mapItem: (el, defaultItem) => ({
|
|
153
|
+
...defaultItem,
|
|
154
|
+
iconName: "star",
|
|
155
|
+
description: `custom:${el.tagName}`,
|
|
156
|
+
}),
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const items = await source.search("", {
|
|
160
|
+
messages: [],
|
|
161
|
+
config: {} as never,
|
|
162
|
+
signal: new AbortController().signal,
|
|
163
|
+
});
|
|
164
|
+
const go = items.find((i) => i.label.includes("Continue"));
|
|
165
|
+
expect(go).toBeTruthy();
|
|
166
|
+
expect(go!.iconName).toBe("star");
|
|
167
|
+
expect(go!.description).toMatch(/^custom:/i);
|
|
168
|
+
|
|
169
|
+
// id stays the selector, so submit-time resolve still reads the live element.
|
|
170
|
+
const payload = await source.resolve(go!, {
|
|
171
|
+
messages: [],
|
|
172
|
+
config: {} as never,
|
|
173
|
+
composerText: "",
|
|
174
|
+
args: "",
|
|
175
|
+
signal: new AbortController().signal,
|
|
176
|
+
});
|
|
177
|
+
expect(payload.llmAppend).toContain("Continue to checkout");
|
|
178
|
+
});
|
|
179
|
+
|
|
119
180
|
it("provider returns formatted context under the configured key", async () => {
|
|
120
181
|
document.body.innerHTML = `<main><button id="go">Continue</button></main>`;
|
|
121
182
|
const provider = createSmartDomReaderContextProvider({
|
|
@@ -132,4 +193,70 @@ describe("smart-dom-reader entry (jsdom)", () => {
|
|
|
132
193
|
document.body.innerHTML = "";
|
|
133
194
|
expect(collectSmartDomContext(JSDOM_OPTS)).toEqual([]);
|
|
134
195
|
});
|
|
196
|
+
|
|
197
|
+
const searchCtx = () => ({
|
|
198
|
+
messages: [],
|
|
199
|
+
config: {} as never,
|
|
200
|
+
signal: new AbortController().signal,
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("reuses the snapshot across empty-query searches within the TTL (scans once)", async () => {
|
|
204
|
+
document.body.innerHTML = `<main><button id="go">Continue to checkout</button></main>`;
|
|
205
|
+
const source = createSmartDomMentionSource({ label: "Page", ...JSDOM_OPTS });
|
|
206
|
+
|
|
207
|
+
// Count extractions by observing DOM reads: a spy on querySelectorAll fires
|
|
208
|
+
// per scan. Simpler and TTL-independent: mutate the DOM between the two empty
|
|
209
|
+
// searches and assert the second still returns the pre-mutation snapshot.
|
|
210
|
+
const first = await source.search("", searchCtx());
|
|
211
|
+
expect(first.some((i) => i.label.includes("Continue"))).toBe(true);
|
|
212
|
+
|
|
213
|
+
// Change the page; a rescan would surface the new element.
|
|
214
|
+
document.body.innerHTML = `<main><button id="new">Brand new action</button></main>`;
|
|
215
|
+
const second = await source.search("", searchCtx());
|
|
216
|
+
// Still the cached snapshot: no rescan happened within the TTL.
|
|
217
|
+
expect(second.some((i) => i.label.includes("Continue"))).toBe(true);
|
|
218
|
+
expect(second.some((i) => i.label.includes("Brand new"))).toBe(false);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("rescans on an empty query once the TTL has elapsed", async () => {
|
|
222
|
+
vi.useFakeTimers();
|
|
223
|
+
try {
|
|
224
|
+
document.body.innerHTML = `<main><button id="go">Continue to checkout</button></main>`;
|
|
225
|
+
const source = createSmartDomMentionSource({ label: "Page", ...JSDOM_OPTS });
|
|
226
|
+
|
|
227
|
+
const first = await source.search("", searchCtx());
|
|
228
|
+
expect(first.some((i) => i.label.includes("Continue"))).toBe(true);
|
|
229
|
+
|
|
230
|
+
// Mutate the page, then advance past the 2s TTL so the next empty query rescans.
|
|
231
|
+
document.body.innerHTML = `<main><button id="new">Brand new action</button></main>`;
|
|
232
|
+
vi.advanceTimersByTime(2500);
|
|
233
|
+
|
|
234
|
+
const second = await source.search("", searchCtx());
|
|
235
|
+
expect(second.some((i) => i.label.includes("Brand new"))).toBe(true);
|
|
236
|
+
expect(second.some((i) => i.label.includes("Continue"))).toBe(false);
|
|
237
|
+
} finally {
|
|
238
|
+
vi.useRealTimers();
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("resolves an item from a cached snapshot against the live element", async () => {
|
|
243
|
+
document.body.innerHTML = `<main><button id="go">Continue to checkout</button></main>`;
|
|
244
|
+
const source = createSmartDomMentionSource({ label: "Page", ...JSDOM_OPTS });
|
|
245
|
+
|
|
246
|
+
// Prime the snapshot, then re-search (cached) to get the item without rescanning.
|
|
247
|
+
await source.search("", searchCtx());
|
|
248
|
+
const items = await source.search("checkout", searchCtx());
|
|
249
|
+
const go = items.find((i) => i.label.includes("Continue"));
|
|
250
|
+
expect(go).toBeTruthy();
|
|
251
|
+
|
|
252
|
+
const payload = await source.resolve(go!, {
|
|
253
|
+
messages: [],
|
|
254
|
+
config: {} as never,
|
|
255
|
+
composerText: "",
|
|
256
|
+
args: "",
|
|
257
|
+
signal: new AbortController().signal,
|
|
258
|
+
});
|
|
259
|
+
expect(payload.llmAppend).toContain("Continue to checkout");
|
|
260
|
+
expect(payload.context).toMatchObject({ selector: go!.id });
|
|
261
|
+
});
|
|
135
262
|
});
|