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
9 changes: 9 additions & 0 deletions .changeset/tool-activity-blocks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@smooai/chat-widget': minor
---

Add an opt-in `showToolActivity` option that renders the agent's tool activity (grep / read_file / bash / knowledge_search…) as inline chips interleaved with its prose, mirroring the smooth daemon SPA's `blocks` model.

- Defaults to **`false`** — a customer-facing support widget stays byte-for-byte unchanged (prose bubble only). Enable via the `show-tool-activity` HTML attribute or `showToolActivity: true` in the config.
- When enabled, an assistant turn that invokes a tool renders as an ordered strip of prose bubbles + tool chips (running… / done ✓ / error), in the order the model produced them. Tool activity is read from `state.rawResponse.toolCall` / `state.rawResponse.toolResult` (the correct nested path — reading `state.toolResult` leaves chips stuck "running…").
- Tool name/args are rendered via `textContent` (never `innerHTML`), so a tool payload can't inject markup. New public `MessageBlock` / `ToolCall` types.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ Declarative attributes mirror the programmatic [`ChatWidgetConfig`](./src/config
| `mode` | `mode` | `popover` (default) or `fullpage`. |
| `start-open` | `startOpen` | Open the panel immediately (no launcher click). |
| `hide-branding` | `hideBranding` | Hide the "powered by smooth-operator" tag + footer link. Default shown. |
| `show-tool-activity` | `showToolActivity` | Show the agent's tool activity as inline chips interleaved with its prose. Default **off** (end-users normally shouldn't see raw tool calls). |
| — | `theme` | Brand colors (see [Theming](#theming)). |
| — | `userName` / `userEmail` | Optional participant identity. |

Expand Down
7 changes: 7 additions & 0 deletions src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ describe('resolveConfig', () => {
expect(r.placeholder).toBe('Type a message…');
expect(r.greeting).toBe('Hi! How can I help you today?');
expect(r.startOpen).toBe(false);
// Tool activity is hidden by default — a customer-facing widget shouldn't
// expose raw tool calls to an end-user unless explicitly opted in.
expect(r.showToolActivity).toBe(false);
expect(r.theme.primary).toBe('#00a6a6');
// The redesign defaults the border to a translucent value for the glass look.
expect(r.theme.border).toBe('rgba(255, 255, 255, 0.1)');
Expand All @@ -22,6 +25,10 @@ describe('resolveConfig', () => {
expect(r.agentId).toBe('agent-1');
});

it('honors showToolActivity when opted in', () => {
expect(resolveConfig({ ...base, showToolActivity: true }).showToolActivity).toBe(true);
});

it('derives userBubble / userBubbleText from primary when unset', () => {
const r = resolveConfig({ ...base, theme: { primary: '#ff0000', primaryText: '#fefefe' } });
expect(r.theme.userBubble).toBe('#ff0000');
Expand Down
11 changes: 11 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,16 @@ export interface ChatWidgetConfig {
* `require*` flags are ignored and the pre-chat form is skipped.
*/
allowAnonymous?: boolean;
/**
* Show the agent's tool activity (grep / read_file / bash / knowledge_search…)
* as inline chips interleaved with its prose, mirroring the smooth daemon SPA.
*
* Defaults to **`false`**: for a customer-facing support widget, surfacing raw
* tool calls to an end-user is usually undesirable, so tool activity is hidden
* and only the assistant's prose renders. Enable it for internal / power-user
* surfaces where seeing what the agent did mid-turn is valuable.
*/
showToolActivity?: boolean;
/** Theme overrides. */
theme?: ChatWidgetTheme;
}
Expand Down Expand Up @@ -194,6 +204,7 @@ export function resolveConfig(config: ChatWidgetConfig): ResolvedConfig {
collectConsent: config.collectConsent ?? true,
allowChatRestore: config.allowChatRestore ?? true,
allowAnonymous: config.allowAnonymous ?? false,
showToolActivity: config.showToolActivity ?? false,
theme: {
text: theme.text ?? '#f8fafc',
background: theme.background ?? '#040d30',
Expand Down
94 changes: 94 additions & 0 deletions src/conversation-tool-blocks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/**
* Tests for interleaved tool-activity blocks in the ConversationController.
*
* Drives `send()` with a scripted turn (a thenable async-iterable of ServerEvents,
* mirroring `@smooai/smooth-operator`'s turn handle) and asserts the emitted
* `ChatMessage.blocks` — verifying the gate (`showToolActivity`), the interleave
* order, tool resolution from the correct `state.rawResponse.toolResult` path, and
* the wrong-path guard.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';

// ── Mock the operator client. `send()` uses: new SmoothAgentClient(), connect(),
// createConversationSession() → {sessionId}, sendMessage() → turn handle.
const scriptedEvents: unknown[] = [];
let scriptedFinal: unknown = { data: { data: { response: { responseParts: [] } } } };

function makeTurn() {
return {
requestId: 'r1',
async *[Symbol.asyncIterator]() {
for (const ev of scriptedEvents) yield ev;
},
then(onFulfilled: (v: unknown) => unknown, onRejected?: (e: unknown) => unknown) {
return Promise.resolve(scriptedFinal).then(onFulfilled, onRejected);
},
};
}

vi.mock('@smooai/smooth-operator', () => ({
ProtocolError: class ProtocolError extends Error {},
SmoothAgentClient: class {
connect = vi.fn(async () => {});
createConversationSession = vi.fn(async () => ({ sessionId: 's1' }));
sendMessage = vi.fn(() => makeTurn());
disconnect = vi.fn();
},
}));

import { type ChatMessage, ConversationController } from './conversation.js';

const token = (t: string) => ({ type: 'stream_token', token: t });
const toolCall = (name: string, args: unknown) => ({ type: 'stream_chunk', data: { state: { rawResponse: { toolCall: { name, arguments: args } } } } });
const toolResult = (name: string, result: unknown, isError = false) => ({
type: 'stream_chunk',
data: { state: { rawResponse: { toolResult: { name, isError, result } } } },
});

/** Run a full turn and return the final assistant message. */
async function runTurn(showToolActivity: boolean, events: unknown[]): Promise<ChatMessage> {
scriptedEvents.length = 0;
scriptedEvents.push(...events);
let latest: ChatMessage[] = [];
const controller = new ConversationController({ endpoint: 'wss://e/ws', agentId: 'a1', showToolActivity }, { onMessages: (m) => (latest = m), onStatus: () => {} });
await controller.send('hi');
return latest.filter((m) => m.role === 'assistant').at(-1)!;
}

describe('ConversationController tool-activity blocks', () => {
beforeEach(() => {
scriptedFinal = { data: { data: { response: { responseParts: ['done'] } } } };
});

it('does not populate blocks when showToolActivity is off (default behavior unchanged)', async () => {
const msg = await runTurn(false, [token('Hello '), toolCall('grep', { q: 'x' }), toolResult('grep', 'ok'), token('there')]);
expect(msg.blocks).toBeUndefined();
expect(msg.text).toBe('Hello there');
});

it('interleaves prose and tool chips in order when enabled', async () => {
const msg = await runTurn(true, [token('Let me look. '), toolCall('search', { q: 'x' }), toolResult('search', 'found'), token('Got it.')]);
expect(msg.blocks?.map((b) => b.kind)).toEqual(['text', 'tool', 'text']);
const tool = msg.blocks!.find((b) => b.kind === 'tool')!;
expect(tool).toMatchObject({ kind: 'tool', tool: { name: 'search', done: true, isError: false, result: 'found' } });
});

it('drops blocks for a prose-only turn even when enabled (no tool invoked)', async () => {
const msg = await runTurn(true, [token('Just talking, no tools.')]);
expect(msg.blocks).toBeUndefined();
});

it('marks a tool errored when the result is an error', async () => {
const msg = await runTurn(true, [toolCall('bash', 'ls /nope'), toolResult('bash', 'no such dir', true)]);
const tool = msg.blocks!.find((b) => b.kind === 'tool')!;
expect(tool).toMatchObject({ kind: 'tool', tool: { done: true, isError: true } });
});

it('leaves a chip running when the result is (wrongly) at state.toolResult', async () => {
// Guards the daemon's hard-won bug: results live at rawResponse.toolResult.
const wrongPathResult = { type: 'stream_chunk', data: { state: { toolResult: { name: 'grep', result: 'x' } } } };
const msg = await runTurn(true, [toolCall('grep', {}), wrongPathResult]);
const tool = msg.blocks!.find((b) => b.kind === 'tool')!;
expect(tool).toMatchObject({ kind: 'tool', tool: { done: false } });
});
});
94 changes: 93 additions & 1 deletion src/conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,13 +61,45 @@ export type { Citation };

export type Role = 'user' | 'assistant';

/**
* One tool invocation within an assistant turn. Mirrors the smooth daemon SPA's
* `ToolCall` (`crates/smooth-web/web/src/operator.ts`): opens `done: false` on the
* tool call and resolves on the tool result.
*/
export interface ToolCall {
/** Stable id for keyed rendering (assigned when the call opens). */
id: string;
name: string;
/** Raw arguments, JSON-stringified. */
args: string;
/** Present once the tool resolves. */
result?: string;
isError?: boolean;
done: boolean;
}

/**
* One ordered segment of an assistant turn: a run of prose, or a tool call.
* Preserves the interleave order the model produced (say a bit → call a tool →
* say a bit → …) so the UI can render tool chips INLINE where the model called
* them. Mirrors the daemon SPA's `MessageBlock`. Only populated when the widget
* is configured with `showToolActivity: true`.
*/
export type MessageBlock = { kind: 'text'; text: string } | { kind: 'tool'; tool: ToolCall };

export interface ChatMessage {
id: string;
role: Role;
/** Accumulated text (assistant messages grow as tokens stream in). */
text: string;
/** True while an assistant message is still streaming. */
streaming: boolean;
/**
* Ordered text + tool segments, interleaved as the model produced them. Present
* only on assistant messages when `showToolActivity` is enabled (absent
* otherwise — the default popover renders `text` alone, byte-for-byte unchanged).
*/
blocks?: MessageBlock[];
/**
* Sources that grounded an assistant answer, when the terminal
* `eventual_response` carried any. Optional + back-compatible: absent when
Expand Down Expand Up @@ -244,6 +276,52 @@ function wireMessageToChat(m: WireMessage, idx: number): ChatMessage | null {
return { id: typeof m.id === 'string' ? m.id : `hist-${idx}`, role, text, streaming: false };
}

let toolSeq = 0;
const nextToolId = (): string => `tool-${++toolSeq}`;

/** Grow the trailing text block, or open a new one if the last block was a tool. */
function growTextBlock(blocks: MessageBlock[], text: string): void {
if (!text) return;
const last = blocks[blocks.length - 1];
if (last && last.kind === 'text') last.text += text;
else blocks.push({ kind: 'text', text });
}

/**
* Fold a `stream_chunk` node-state into the ordered block list, returning `true`
* when the chunk carried tool activity.
*
* Tool activity rides `state.rawResponse.toolCall` / `state.rawResponse.toolResult`
* — **NOT** `state.toolResult`. Reading the wrong path leaves every chip stuck on
* "running…" forever (the exact bug the daemon SPA hit and this mirror avoids).
*/
function applyToolChunk(blocks: MessageBlock[], state: unknown): boolean {
const raw = (state as { rawResponse?: unknown } | null | undefined)?.rawResponse;
if (!raw || typeof raw !== 'object') return false;
const call = (raw as { toolCall?: { name?: string; arguments?: unknown } }).toolCall;
const res = (raw as { toolResult?: { name?: string; isError?: boolean; result?: unknown } }).toolResult;
if (call) {
const args = typeof call.arguments === 'string' ? call.arguments : JSON.stringify(call.arguments ?? {});
blocks.push({ kind: 'tool', tool: { id: nextToolId(), name: call.name ?? '', args, done: false } });
return true;
}
if (res) {
const result = typeof res.result === 'string' ? res.result : JSON.stringify(res.result ?? '');
// Complete the most-recent still-open tool block matching this name.
for (let i = blocks.length - 1; i >= 0; i--) {
const b = blocks[i];
if (b && b.kind === 'tool' && b.tool.name === (res.name ?? '') && !b.tool.done) {
b.tool.done = true;
b.tool.isError = !!res.isError;
b.tool.result = result;
break;
}
}
return true;
}
return false;
}

export class ConversationController {
private readonly config: ChatWidgetConfig;
private readonly events: ConversationEvents;
Expand Down Expand Up @@ -674,7 +752,8 @@ export class ConversationController {
this.messages.push({ id: this.nextId('u'), role: 'user', text: trimmed, streaming: false });

// 2. Placeholder assistant bubble we grow as tokens arrive.
const assistant: ChatMessage = { id: this.nextId('a'), role: 'assistant', text: '', streaming: true };
const showTools = this.config.showToolActivity === true;
const assistant: ChatMessage = { id: this.nextId('a'), role: 'assistant', text: '', streaming: true, blocks: showTools ? [] : undefined };
this.messages.push(assistant);
this.emitMessages();

Expand All @@ -687,6 +766,14 @@ export class ConversationController {
const token = event.token ?? event.data?.token ?? '';
if (token) {
assistant.text += token;
// Grow the trailing text block so prose interleaves with any
// tool chips in the order the model produced them.
if (showTools && assistant.blocks) growTextBlock(assistant.blocks, 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)) {
this.emitMessages();
}
} else {
Expand Down Expand Up @@ -715,6 +802,11 @@ export class ConversationController {
if (suggestions.length > 0) {
assistant.suggestions = suggestions;
}
// Only keep blocks for turns that actually invoked a tool — a prose-only
// turn drops back to the normal markdown text path (with the final text).
if (assistant.blocks && !assistant.blocks.some((b) => b.kind === 'tool')) {
assistant.blocks = undefined;
}
assistant.streaming = false;
this.emitMessages();
} catch (err) {
Expand Down
Loading
Loading