From 8698efaf363ad89703cdbd70009ae1f250b816d8 Mon Sep 17 00:00:00 2001 From: Brent Rager Date: Wed, 15 Jul 2026 17:36:59 -0400 Subject: [PATCH 1/2] th-9a5794: render stream_preamble as an ephemeral pre-answer sentence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the operator streams a fast-model preamble ahead of the answer (covering the reasoning model's time-to-first-token), show it in the assistant bubble's typing slot — muted + italic, in place of the bare typing dots — then swap in the real answer the instant it starts streaming. A late preamble frame (arriving after the answer began) is ignored, matching the server-side guard. Requires @smooai/smooth-operator >= 1.22.15 (adds stream_preamble to the event union so the SDK client forwards the frame instead of dropping it as unknown). Verified: 172/172 widget tests pass against a 1.22.15 SDK, including 2 new tests (preamble shown then replaced by the answer; late preamble ignored). typecheck clean. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01WEUZgsyfGVab8uqSFPbr23 --- .changeset/stream-preamble.md | 5 +++ package.json | 2 +- src/conversation.test.ts | 68 +++++++++++++++++++++++++++++++++++ src/conversation.ts | 22 ++++++++++++ src/element.ts | 14 ++++++-- src/styles.ts | 8 +++++ 6 files changed, 115 insertions(+), 4 deletions(-) create mode 100644 .changeset/stream-preamble.md diff --git a/.changeset/stream-preamble.md b/.changeset/stream-preamble.md new file mode 100644 index 0000000..e66a188 --- /dev/null +++ b/.changeset/stream-preamble.md @@ -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). diff --git a/package.json b/package.json index 1c3957e..cf88f7b 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/src/conversation.test.ts b/src/conversation.test.ts index 90b1b5c..02c029d 100644 --- a/src/conversation.test.ts +++ b/src/conversation.test.ts @@ -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(); + }); +}); diff --git a/src/conversation.ts b/src/conversation.ts index cff941e..a8c22ad 100644 --- a/src/conversation.ts +++ b/src/conversation.ts @@ -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 @@ -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). @@ -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)) { diff --git a/src/element.ts b/src/element.ts index eddc709..87aba7a 100644 --- a/src/element.ts +++ b/src/element.ts @@ -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. diff --git a/src/styles.ts b/src/styles.ts index 668dded..2afa2a5 100644 --- a/src/styles.ts +++ b/src/styles.ts @@ -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: ''; From b4372ec6d91af38ccc745c25c41a9761ec8e6efd Mon Sep 17 00:00:00 2001 From: Brent Rager Date: Wed, 15 Jul 2026 17:44:29 -0400 Subject: [PATCH 2/2] th-9a5794: lock @smooai/smooth-operator 1.22.17 (has stream_preamble) --- pnpm-lock.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6787ffc..2619cbb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,8 +10,8 @@ importers: .: dependencies: '@smooai/smooth-operator': - specifier: ^1.21.1 - version: 1.21.1 + specifier: ^1.22.15 + version: 1.22.17 libphonenumber-js: specifier: ^1.13.7 version: 1.13.7 @@ -575,8 +575,8 @@ packages: cpu: [x64] os: [win32] - '@smooai/smooth-operator@1.21.1': - resolution: {integrity: sha512-lWz4uZt5lRPOuhFO9QwDKY4OPAWH8JjK6aefO8/yFiGSBju9UFwJxmkj9ypJ4AcGI97zNsutDEP0mfE8EgsP+A==} + '@smooai/smooth-operator@1.22.17': + resolution: {integrity: sha512-j2GfvysQOIwd86iuFrk7kAIkD/M6g8YwIljxMFZGVMn0E5red7d/v9kZ080nOMqJnDhbcYpHqPi5vgFttq85Og==} engines: {node: '>=22'} peerDependencies: react: '>=18' @@ -1986,7 +1986,7 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.62.0': optional: true - '@smooai/smooth-operator@1.21.1': + '@smooai/smooth-operator@1.22.17': dependencies: ajv: 8.20.0 ajv-formats: 3.0.1(ajv@8.20.0)