Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/stream-preamble.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@smooai/chat-widget': minor
---

Render the new `stream_preamble` frame: when the operator streams a fast-model preamble ("what I'm about to do") ahead of the answer, show it in the assistant bubble's typing slot (muted + italic) instead of the bare typing dots, then swap in the real answer the moment it starts streaming. A late preamble frame (after the answer began) is ignored. Requires `@smooai/smooth-operator` ≥ 1.22.15 (adds `stream_preamble` to the event union so the client forwards it).
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@
"ci:publish": "pnpm build && changeset publish"
},
"dependencies": {
"@smooai/smooth-operator": "^1.21.1",
"@smooai/smooth-operator": "^1.22.15",
"libphonenumber-js": "^1.13.7",
"zustand": "^5.0.14"
},
Expand Down
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

68 changes: 68 additions & 0 deletions src/conversation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -907,3 +907,71 @@ describe('ConversationController — Rich Interactions (identity_intake card)',
});
});


describe('ConversationController — fast-model preamble (stream_preamble)', () => {
beforeEach(() => {
localStorage.clear();
installMockWs();
installMockFetch();
MockSocket.onFrame = defaultOnFrame;
});
afterEach(() => localStorage.clear());

it('shows the preamble in the typing slot, then the answer replaces it', async () => {
MockSocket.onFrame = (frame, reply) => {
const requestId = frame.requestId;
if (frame.action === 'create_conversation_session') {
reply({ type: 'immediate_response', requestId, status: 202, data: { sessionId: 'sess-new', conversationId: 'conv-1', agentId: frame.agentId } });
} else if (frame.action === 'send_message') {
reply({ type: 'immediate_response', requestId, status: 202, data: {} });
// Preamble arrives BEFORE any answer token (the whole point).
reply({ type: 'stream_preamble', requestId, token: 'Let me check that.', data: { requestId, token: 'Let me check that.' } });
reply({ type: 'stream_token', requestId, token: 'Hi' });
reply({ type: 'eventual_response', requestId, status: 200, data: { requestId, status: 200, data: { messageId: 'm1', response: { responseParts: ['Hi there'] } } } });
}
};
const { controller, onMessages } = makeController();
await controller.connect();
await controller.send('hello');
await tick();

type Snap = Array<{ role: string; text: string; preamble?: string }>;
const snaps = onMessages.mock.calls.map((c) => c[0] as Snap);

// At some point the assistant bubble carried the preamble with NO answer text yet.
const showedPreamble = snaps.some((s) => {
const a = s.find((m) => m.role === 'assistant');
return a?.preamble === 'Let me check that.' && a.text === '';
});
expect(showedPreamble).toBe(true);

// The final assistant message is the real answer, and the preamble is gone.
const finalAssistant = snaps.at(-1)!.find((m) => m.role === 'assistant')!;
expect(finalAssistant.text).toBe('Hi there');
expect(finalAssistant.preamble).toBeUndefined();
});

it('ignores a preamble frame that arrives after the answer already started', async () => {
MockSocket.onFrame = (frame, reply) => {
const requestId = frame.requestId;
if (frame.action === 'create_conversation_session') {
reply({ type: 'immediate_response', requestId, status: 202, data: { sessionId: 'sess-new', conversationId: 'conv-1', agentId: frame.agentId } });
} else if (frame.action === 'send_message') {
reply({ type: 'immediate_response', requestId, status: 202, data: {} });
reply({ type: 'stream_token', requestId, token: 'Answer' });
// A late preamble must NOT clobber the in-flight answer.
reply({ type: 'stream_preamble', requestId, token: 'too late', data: { requestId, token: 'too late' } });
reply({ type: 'eventual_response', requestId, status: 200, data: { requestId, status: 200, data: { messageId: 'm1', response: { responseParts: ['Answer'] } } } });
}
};
const { controller, onMessages } = makeController();
await controller.connect();
await controller.send('hello');
await tick();

type Snap = Array<{ role: string; text: string; preamble?: string }>;
const snaps = onMessages.mock.calls.map((c) => c[0] as Snap);
const anyPreamble = snaps.some((s) => s.find((m) => m.role === 'assistant')?.preamble);
expect(anyPreamble).toBeFalsy();
});
});
22 changes: 22 additions & 0 deletions src/conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,14 @@ export interface ChatMessage {
text: string;
/** True while an assistant message is still streaming. */
streaming: boolean;
/**
* Ephemeral "what I'm about to do" sentence from the fast-model preamble
* (`stream_preamble`), shown in the typing slot while `text` is still empty
* to cover the main model's time-to-first-token. Cleared the moment the real
* answer starts streaming (first `stream_token`). Absent unless the server
* emits a preamble (`SMOOTH_AGENT_PREAMBLE_MODEL`).
*/
preamble?: string;
/**
* Ordered text + tool segments, interleaved as the model produced them. Present
* only on assistant messages when `showToolActivity` is enabled (absent
Expand Down Expand Up @@ -808,6 +816,8 @@ export class ConversationController {
if (event.type === 'stream_token') {
const token = event.token ?? event.data?.token ?? '';
if (token) {
// The real answer has begun — retire the ephemeral preamble.
if (assistant.preamble) assistant.preamble = undefined;
// Hold the streamed text to the same paragraph shape the finalized
// render uses (PARAGRAPH_SEP) so no transient extra blank line
// flickers mid-stream then vanishes on finalize (SMOODEV-2534).
Expand All @@ -818,6 +828,18 @@ export class ConversationController {
if (showTools && assistant.blocks) growTextBlock(assistant.blocks, token);
this.emitMessages();
}
} else if ((event as { type?: string }).type === 'stream_preamble') {
// Fast-model preamble: show it in the typing slot ONLY while no
// answer text has arrived yet (a late frame after the answer
// started is ignored — the server also guards this). Cast because
// the pinned SDK's union may predate `stream_preamble` (added in
// @smooai/smooth-operator 1.22.15); shape mirrors stream_token.
const pre = event as { token?: string; data?: { token?: string } };
const token = pre.token ?? pre.data?.token ?? '';
if (token && !assistant.text) {
assistant.preamble = (assistant.preamble ?? '') + token;
this.emitMessages();
}
} else if (showTools && event.type === 'stream_chunk') {
// Tool activity (gated). Read state.rawResponse.toolCall/.toolResult.
if (assistant.blocks && applyToolChunk(assistant.blocks, event.data?.state)) {
Expand Down
14 changes: 11 additions & 3 deletions src/element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1167,9 +1167,17 @@ export class SmoothAgentChatElement extends HTMLElement {
const bubble = document.createElement('div');
bubble.className = `bubble ${msg.role}`;
if (msg.role === 'assistant' && msg.streaming && !msg.text) {
// No text yet → animated typing indicator.
bubble.classList.add('typing');
bubble.append(this.typingDot(), this.typingDot(), this.typingDot());
if (msg.preamble) {
// Fast-model preamble covering the main model's time-to-first-
// token: show the "what I'm about to do" sentence (plain text)
// instead of bare dots. Replaced by the answer once it streams.
bubble.classList.add('preamble');
bubble.textContent = msg.preamble;
} else {
// No text or preamble yet → animated typing indicator.
bubble.classList.add('typing');
bubble.append(this.typingDot(), this.typingDot(), this.typingDot());
}
} else if (msg.streaming) {
// Mid-stream: partial/unclosed markdown renders ugly (half-open
// `**`, dangling `[`), so keep plain text + the blinking cursor.
Expand Down
8 changes: 8 additions & 0 deletions src/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,14 @@ ${
.bubble.typing i:nth-child(2) { animation-delay: .18s; }
.bubble.typing i:nth-child(3) { animation-delay: .36s; }
@keyframes sac-typing { 0%, 60%, 100% { transform: translateY(0); opacity: .4 } 30% { transform: translateY(-5px); opacity: 1 } }
/* Fast-model preamble ("what I'm about to do") shown in place of the typing dots
until the real answer streams. Muted + italic so it reads as a transient status,
not the answer. */
.bubble.preamble {
font-style: italic;
opacity: .72;
color: color-mix(in srgb, var(--sac-assistant-bubble-text) 75%, transparent);
}

.cursor::after {
content: '';
Expand Down
Loading