diff --git a/app/components/atoms/ChatBubble.vue b/app/components/atoms/ChatBubble.vue index 2cad5618..d45b620b 100644 --- a/app/components/atoms/ChatBubble.vue +++ b/app/components/atoms/ChatBubble.vue @@ -1,8 +1,7 @@ + + diff --git a/app/components/organisms/ChatPanel.vue b/app/components/organisms/ChatPanel.vue index 8d9e498c..d27e513e 100644 --- a/app/components/organisms/ChatPanel.vue +++ b/app/components/organisms/ChatPanel.vue @@ -16,7 +16,7 @@ const emit = defineEmits<{ }>() const { t } = useContent() -const { messages, conversationId, conversations, isStreaming, error, selectedModel, sendMessage, stopStreaming, clearChat, fetchConversations, loadConversation, deleteConversation } = useChat({ +const { messages, conversationId, conversations, isStreaming, error, streamTick, selectedModel, sendMessage, stopStreaming, clearChat, fetchConversations, loadConversation, deleteConversation } = useChat({ onContentChanged: (affected) => { emit('contentChanged', affected) }, @@ -49,9 +49,22 @@ const { chips, toContextItems, clear: clearContext } = useChatContext() const { state: authState } = useAuth() const toast = useToast() const messagesEndRef = ref(null) +const scrollContainerRef = ref(null) const historyOpen = ref(false) const confirmDeleteId = ref(null) +// Scroll-follow: keep the view pinned to the bottom while a turn +// streams, but release the pin the moment the user scrolls up to read. +// Scrolling back near the bottom re-pins. +const PIN_THRESHOLD_PX = 96 +const isPinned = ref(true) + +function onMessagesScroll() { + const el = scrollContainerRef.value + if (!el) return + isPinned.value = el.scrollHeight - el.scrollTop - el.clientHeight < PIN_THRESHOLD_PX +} + async function handleSend(text: string, attachments?: UIAttachment[]) { // Capture chips before clearing const contextItems = toContextItems() @@ -89,6 +102,16 @@ watch( }, ) +// Scroll-follow during streaming: every content-bearing SSE event bumps +// `streamTick`; while pinned, keep the anchor in view. `auto` (not +// `smooth`) so rapid deltas don't queue competing animations. +watch(streamTick, () => { + if (!isPinned.value) return + nextTick(() => { + messagesEndRef.value?.scrollIntoView({ behavior: 'auto' }) + }) +}) + // Show error toast watch(error, (err) => { if (err) toast.error(err) @@ -231,7 +254,7 @@ function formatConversationDate(dateStr: string): string { -
+
@@ -273,23 +296,39 @@ function formatConversationDate(dateStr: string): string {
- + - -
- + +
+
+
+ +
+
+
+ +
diff --git a/app/composables/useChat.ts b/app/composables/useChat.ts index 0e933860..4b398c93 100644 --- a/app/composables/useChat.ts +++ b/app/composables/useChat.ts @@ -13,6 +13,17 @@ export interface ToolCall { status: 'pending' | 'streaming' | 'complete' | 'error' } +/** + * One chronological slice of an assistant turn. A multi-iteration + * agent turn interleaves narration and tool execution (text → tools → + * text → tools…); segments preserve that order so the transcript reads + * as a step-by-step record instead of one glued text wall with all + * tool cards dumped at the end. + */ +export type MessageSegment + = | { kind: 'text', text: string } + | { kind: 'tool', call: ToolCall } + /** How an attachment renders in a message bubble. */ export interface MessageAttachment { kind: 'text' | 'document' | 'image' @@ -49,8 +60,8 @@ export interface UIAttachment { export interface ChatMessage { id: string role: 'user' | 'assistant' - text: string - toolCalls: ToolCall[] + /** Ordered narration/tool slices — see `MessageSegment`. */ + segments: MessageSegment[] createdAt: string /** Context items attached to this message (user messages only) */ contextItems?: Array<{ @@ -62,6 +73,28 @@ export interface ChatMessage { attachments?: MessageAttachment[] } +/** Structural view accepted by the message helpers — also matches the + * deep-readonly messages exposed by `useChat()`. */ +interface MessageView { + segments: readonly MessageSegment[] + attachments?: readonly unknown[] +} + +/** Concatenated text of a message's text segments. */ +export function messageText(msg: MessageView): string { + let out = '' + for (const seg of msg.segments) { + if (seg.kind === 'text') out += seg.text + } + return out +} + +/** Whether a message has anything worth rendering (or keeping on error). */ +export function hasVisibleContent(msg: MessageView): boolean { + if (msg.attachments?.length) return true + return msg.segments.some(seg => seg.kind === 'tool' || seg.text.trim().length > 0) +} + /** UI context sent with each message */ export interface ChatUIContext { activeModelId: string | null @@ -131,6 +164,54 @@ function hydrateAttachments(blocks: unknown): MessageAttachment[] | undefined { return out.length ? out : undefined } +/** A persisted transcript row as returned by the messages endpoint. */ +interface ConversationRow { + id: string + role: string + content: string + content_blocks: unknown + tool_calls: unknown + created_at: string +} + +/** + * Build ordered segments from a persisted assistant row. + * + * Priority: `content_blocks` (post-009 trace rows — the real + * text/tool_use order Claude produced; the plain `content` column is a + * text-only fallback that degrades to a literal `[tool calls]` + * placeholder for tool-only iterations, so it must never render when + * blocks exist) → legacy `tool_calls` wrapper (pre-trace rows) → + * plain `content` text. + */ +function rowToSegments(row: ConversationRow): MessageSegment[] { + if (Array.isArray(row.content_blocks) && row.content_blocks.length > 0) { + const segments: MessageSegment[] = [] + for (const raw of row.content_blocks) { + const b = raw as { type?: string, text?: string, id?: string, name?: string, input?: unknown } + if (b?.type === 'text' && typeof b.text === 'string' && b.text.trim()) { + segments.push({ kind: 'text', text: b.text }) + } + else if (b?.type === 'tool_use' && b.id && b.name) { + // Results live on internal tool_result rows that the transcript + // endpoint doesn't return — the card renders name + input only. + segments.push({ kind: 'tool', call: { id: b.id, name: b.name, input: b.input ?? null, status: 'complete' } }) + } + } + if (segments.length > 0) return segments + } + + const segments: MessageSegment[] = [] + if (row.content.trim()) segments.push({ kind: 'text', text: row.content }) + if (Array.isArray(row.tool_calls)) { + for (const raw of row.tool_calls as Array<{ id: string, name: string, input: unknown, result?: unknown }>) { + if (!raw.name) continue + segments.push({ kind: 'tool', call: { id: raw.id, name: raw.name, input: raw.input, result: raw.result, status: 'complete' } }) + } + } + return segments +} + export function useChat(options?: { onContentChanged?: (affected: AffectedResources) => void }) { @@ -140,6 +221,10 @@ export function useChat(options?: { const isStreaming = useState('chat-streaming', () => false) const error = useState('chat-error', () => null) const selectedModel = useState('chat-model', () => DEFAULT_CHAT_MODEL) + // Monotonic counter bumped on every content-bearing SSE event. The + // panel watches it for scroll-follow — cheaper than deep-watching the + // whole message tree. + const streamTick = useState('chat-stream-tick', () => 0) // Module-instance abort handle so the composer can stop a stream mid-flight. let abortController: AbortController | null = null @@ -204,29 +289,17 @@ export function useChat(options?: { async function loadConversation(workspaceId: string, projectId: string, convId: string) { try { - const data = await $fetch>(`/api/workspaces/${workspaceId}/projects/${projectId}/conversations/${convId}/messages`) + const data = await $fetch( + `/api/workspaces/${workspaceId}/projects/${projectId}/conversations/${convId}/messages`, + ) // Convert DB messages to ChatMessage format messages.value = data.map(row => ({ id: row.id, role: row.role as 'user' | 'assistant', - text: row.content, - toolCalls: Array.isArray(row.tool_calls) - ? (row.tool_calls as Array<{ id: string, name: string, input: unknown, result?: unknown }>).filter(b => b.name).map(b => ({ - id: b.id, - name: b.name, - input: b.input, - result: b.result, - status: 'complete' as const, - })) - : [], + segments: row.role === 'user' + ? (row.content.trim() ? [{ kind: 'text' as const, text: row.content }] : []) + : rowToSegments(row), createdAt: row.created_at, attachments: row.role === 'user' ? hydrateAttachments(row.content_blocks) : undefined, })) @@ -275,8 +348,7 @@ export function useChat(options?: { const userMsg: ChatMessage = { id: `user-${Date.now()}`, role: 'user', - text, - toolCalls: [], + segments: [{ kind: 'text', text }], createdAt: new Date().toISOString(), contextItems: attachedChips?.length ? attachedChips : undefined, attachments: readyAttachments.length @@ -289,8 +361,7 @@ export function useChat(options?: { const assistantMsg: ChatMessage = { id: `assistant-${Date.now()}`, role: 'assistant', - text: '', - toolCalls: [], + segments: [], createdAt: new Date().toISOString(), } messages.value.push(assistantMsg) @@ -370,7 +441,7 @@ export function useChat(options?: { catch (e: unknown) { // User-initiated stop: keep whatever already streamed, no error toast. if ((e as Error)?.name === 'AbortError') { - if (!assistantMsg.text && assistantMsg.toolCalls.length === 0) { + if (!hasVisibleContent(assistantMsg)) { messages.value.pop() } } @@ -378,7 +449,7 @@ export function useChat(options?: { const { t } = useContent() error.value = resolveApiError(e, t('chat.send_error')) // Remove empty assistant message on error - if (!assistantMsg.text && assistantMsg.toolCalls.length === 0) { + if (!hasVisibleContent(assistantMsg)) { messages.value.pop() } } @@ -393,34 +464,65 @@ export function useChat(options?: { } } + /** Latest tool segment matching an id — searched from the end because + * tool ids are unique per turn and live updates always target the + * most recent calls. */ + function findToolCall(msg: ChatMessage, id: unknown): ToolCall | undefined { + for (let i = msg.segments.length - 1; i >= 0; i--) { + const seg = msg.segments[i]! + if (seg.kind === 'tool' && seg.call.id === id) return seg.call + } + return undefined + } + function handleSSEEvent(event: Record, msg: ChatMessage) { switch (event.type) { case 'conversation': conversationId.value = event.id as string break - case 'text': - msg.text += event.content as string + case 'text': { + const last = msg.segments[msg.segments.length - 1] + if (last?.kind === 'text') last.text += event.content as string + else msg.segments.push({ kind: 'text', text: event.content as string }) + streamTick.value++ break + } case 'tool_use': - msg.toolCalls.push({ - id: event.id as string, - name: event.name as string, - input: null, - status: 'pending', + msg.segments.push({ + kind: 'tool', + call: { + id: event.id as string, + name: event.name as string, + input: null, + status: 'pending', + }, }) + streamTick.value++ + break + + case 'tool_input': { + // Input arrives when the model finishes emitting the call — + // before the (potentially long) execution — so the card shows + // what's being done while it's pending. + const tc = findToolCall(msg, event.id) + if (tc) tc.input = event.input ?? null + streamTick.value++ break + } case 'tool_result': { - const tc = msg.toolCalls.find(t => t.id === event.id) + const tc = findToolCall(msg, event.id) if (tc) { tc.result = event.result + if (event.input !== undefined && tc.input == null) tc.input = event.input tc.status = 'complete' } // Live refresh: reflect this operation in the context panel without // waiting for the whole turn to finish (debounced + coalesced). scheduleContentRefresh(event.affected as AffectedResources | undefined) + streamTick.value++ break } @@ -442,6 +544,7 @@ export function useChat(options?: { ? t('chat.output_truncated') // Other SSE error events may carry raw backend messages — use fallback : t('chat.send_error') + streamTick.value++ break } } @@ -459,6 +562,7 @@ export function useChat(options?: { conversations: readonly(conversations), isStreaming: readonly(isStreaming), error: readonly(error), + streamTick: readonly(streamTick), selectedModel, sendMessage, stopStreaming, diff --git a/server/utils/conversation-engine.ts b/server/utils/conversation-engine.ts index 52baee0d..7037f1a0 100644 --- a/server/utils/conversation-engine.ts +++ b/server/utils/conversation-engine.ts @@ -22,7 +22,7 @@ import { DEFAULT_MAX_OUTPUT_TOKENS } from '../../shared/utils/ai-models' // ─── Event Types ─── export interface ConversationEvent { - type: 'conversation' | 'text' | 'tool_use' | 'tool_result' | 'done' | 'error' + type: 'conversation' | 'text' | 'tool_use' | 'tool_input' | 'tool_result' | 'done' | 'error' [key: string]: unknown } @@ -183,6 +183,10 @@ export async function* runConversationLoop( const input = (typeof streamEvent.toolInput === 'object' && streamEvent.toolInput !== null) ? streamEvent.toolInput : {} currentToolCalls.push({ id: streamEvent.toolId!, name: streamEvent.toolName!, input }) assistantBlocks.push({ type: 'tool_use', id: streamEvent.toolId!, name: streamEvent.toolName!, input }) + // The model has finished emitting the call but execution hasn't + // started — surface the input now so the client card can show + // what's being done during the (potentially long) pending window. + yield { type: 'tool_input', id: streamEvent.toolId, input } break } case 'message_end': @@ -278,7 +282,7 @@ export async function* runConversationLoop( const stateCheck = checkStateTransition(toolCtx.phase, tc.name) if (!stateCheck.allowed) { const errorResult = { error: stateCheck.reason, suggestion: stateCheck.suggestion } - yield { type: 'tool_result', id: tc.id, name: tc.name, result: errorResult } + yield { type: 'tool_result', id: tc.id, name: tc.name, input: tc.input, result: errorResult } toolResultBlocks.push({ type: 'tool_result', toolUseId: tc.id, content: JSON.stringify(errorResult) }) continue } @@ -304,7 +308,7 @@ export async function* runConversationLoop( // Carry this tool's affected resources on the event so the client // can refresh the context panel live (debounced) as each operation // lands, instead of only once the whole turn finishes on `done`. - yield { type: 'tool_result', id: tc.id, name: tc.name, result: result.result, affected: result.affected } + yield { type: 'tool_result', id: tc.id, name: tc.name, input: tc.input, result: result.result, affected: result.affected } toolResultBlocks.push({ type: 'tool_result', toolUseId: tc.id, content: resultStr }) } diff --git a/tests/e2e/chat-stream.e2e.test.ts b/tests/e2e/chat-stream.e2e.test.ts index 09b1a9f9..0c1871f4 100644 --- a/tests/e2e/chat-stream.e2e.test.ts +++ b/tests/e2e/chat-stream.e2e.test.ts @@ -81,7 +81,9 @@ describe('chat stream e2e', () => { { type: 'conversation', id: 'conv-1' }, { type: 'text', content: 'I reviewed the homepage copy.' }, { type: 'tool_use', id: 'tool-1', name: 'read_content' }, + { type: 'tool_input', id: 'tool-1', input: { model: 'pages', locale: 'en' } }, { type: 'tool_result', id: 'tool-1', result: { entries: 1, locale: 'en' } }, + { type: 'text', content: 'All entries look good.' }, { type: 'done', affected: { @@ -102,7 +104,20 @@ describe('chat stream e2e', () => { await page.getByText('Review the homepage copy').waitFor() await page.getByText('I reviewed the homepage copy.').waitFor() + await page.getByText('All entries look good.').waitFor() + + // Chronology: narration → tool card → closing text, in DOM order. + const bodyText = await page.evaluate(() => document.body.innerText) + const narrationIdx = bodyText.indexOf('I reviewed the homepage copy.') + const toolIdx = bodyText.indexOf('read_content') + const closingIdx = bodyText.indexOf('All entries look good.') + expect(narrationIdx).toBeGreaterThanOrEqual(0) + expect(toolIdx).toBeGreaterThan(narrationIdx) + expect(closingIdx).toBeGreaterThan(toolIdx) + + // Expanded card shows the input (sent via tool_input) and the result. await page.getByRole('button', { name: /read_content/i }).click() + await page.getByText('"model": "pages"').waitFor() await page.getByText('"entries": 1').waitFor() expect(chatBodies).toEqual([ diff --git a/tests/nuxt/composables/use-chat.nuxt.test.ts b/tests/nuxt/composables/use-chat.nuxt.test.ts index a3e26187..fac5919c 100644 --- a/tests/nuxt/composables/use-chat.nuxt.test.ts +++ b/tests/nuxt/composables/use-chat.nuxt.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { useChat } from '../../../app/composables/useChat' +import { messageText, useChat } from '../../../app/composables/useChat' -function createStreamResponse(chunks: string[]) { +function createStreamResponse(chunks: string[], options?: { failAfter?: Error }) { let index = 0 return { @@ -11,6 +11,7 @@ function createStreamResponse(chunks: string[]) { return { async read() { if (index >= chunks.length) { + if (options?.failAfter) throw options.failAfter return { done: true, value: undefined } } const chunk = new TextEncoder().encode(chunks[index]) @@ -31,9 +32,10 @@ describe('useChat', () => { useState('chat-streaming').value = false useState('chat-error').value = null useState('chat-model').value = 'claude-sonnet-4-6' + useState('chat-stream-tick').value = 0 }) - it('loads existing conversations and maps tool calls into chat messages', async () => { + it('loads legacy conversations and maps tool_calls into tool segments', async () => { vi.stubGlobal('$fetch', vi.fn().mockResolvedValue([ { id: 'message-1', @@ -48,16 +50,44 @@ describe('useChat', () => { await chat.loadConversation('workspace-1', 'project-1', 'conv-1') expect(chat.conversationId.value).toBe('conv-1') - expect(chat.messages.value[0]).toMatchObject({ - id: 'message-1', - role: 'assistant', - text: 'Saved successfully', + const msg = chat.messages.value[0]! + expect(msg).toMatchObject({ id: 'message-1', role: 'assistant' }) + expect(msg.segments).toHaveLength(2) + expect(msg.segments[0]).toEqual({ kind: 'text', text: 'Saved successfully' }) + expect(msg.segments[1]).toMatchObject({ + kind: 'tool', + call: { id: 'tool-1', name: 'save_content', status: 'complete' }, }) - expect(chat.messages.value[0]?.toolCalls[0]).toMatchObject({ - id: 'tool-1', - name: 'save_content', - status: 'complete', + }) + + it('hydrates segments from content_blocks and never renders the [tool calls] placeholder', async () => { + vi.stubGlobal('$fetch', vi.fn().mockResolvedValue([ + { + id: 'message-1', + role: 'assistant', + // Tool-only iteration: content column carries the placeholder, + // content_blocks carries the real trace. + content: '[tool calls]', + content_blocks: [ + { type: 'text', text: 'Şimdi kaydediyorum.' }, + { type: 'tool_use', id: 'tool-1', name: 'save_content', input: { model: 'faq' } }, + ], + tool_calls: null, + created_at: '2026-03-25T00:00:00.000Z', + }, + ])) + + const chat = useChat() + await chat.loadConversation('workspace-1', 'project-1', 'conv-1') + + const msg = chat.messages.value[0]! + expect(msg.segments).toHaveLength(2) + expect(msg.segments[0]).toEqual({ kind: 'text', text: 'Şimdi kaydediyorum.' }) + expect(msg.segments[1]).toMatchObject({ + kind: 'tool', + call: { id: 'tool-1', name: 'save_content', input: { model: 'faq' }, status: 'complete' }, }) + expect(messageText(msg)).not.toContain('[tool calls]') }) it('streams assistant text, tool results, and affected resources from sse', async () => { @@ -77,13 +107,17 @@ describe('useChat', () => { expect(chat.conversationId.value).toBe('conv-1') expect(chat.messages.value).toHaveLength(2) - expect(chat.messages.value[0]?.text).toBe('FAQ kaydet') - expect(chat.messages.value[1]?.text).toBe('Merhaba') - expect(chat.messages.value[1]?.toolCalls[0]).toMatchObject({ - id: 'tool-1', - name: 'save_content', - result: { branch: 'cr/content/faq/tr/1234567890-abcd' }, - status: 'complete', + expect(messageText(chat.messages.value[0]!)).toBe('FAQ kaydet') + const assistant = chat.messages.value[1]! + expect(assistant.segments[0]).toEqual({ kind: 'text', text: 'Merhaba' }) + expect(assistant.segments[1]).toMatchObject({ + kind: 'tool', + call: { + id: 'tool-1', + name: 'save_content', + result: { branch: 'cr/content/faq/tr/1234567890-abcd' }, + status: 'complete', + }, }) expect(onContentChanged).toHaveBeenCalledWith({ models: ['faq'], @@ -93,6 +127,105 @@ describe('useChat', () => { }) }) + it('keeps narration and tool calls in chronological segments across iterations', async () => { + vi.stubGlobal('$fetch', vi.fn().mockResolvedValue([])) + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(createStreamResponse([ + 'data: {"type":"text","content":"TR içeriğini kaydediyorum."}\n', + 'data: {"type":"tool_use","id":"t1","name":"save_content"}\n', + 'data: {"type":"tool_result","id":"t1","result":{"ok":true}}\n', + 'data: {"type":"text","content":"Şimdi EN lokali."}\n', + 'data: {"type":"tool_use","id":"t2","name":"save_content"}\n', + 'data: {"type":"tool_result","id":"t2","result":{"ok":true}}\n', + 'data: {"type":"text","content":"Bitti."}\n', + 'data: {"type":"done","affected":{"models":[],"locales":[],"snapshotChanged":false,"branchesChanged":false}}\n', + ]))) + + const chat = useChat() + await chat.sendMessage('workspace-1', 'project-1', 'iki lokale kaydet') + + const assistant = chat.messages.value[1]! + expect(assistant.segments.map(s => s.kind)).toEqual(['text', 'tool', 'text', 'tool', 'text']) + // Iteration texts stay separate segments — not glued into one wall. + expect(assistant.segments[0]).toEqual({ kind: 'text', text: 'TR içeriğini kaydediyorum.' }) + expect(assistant.segments[2]).toEqual({ kind: 'text', text: 'Şimdi EN lokali.' }) + expect(assistant.segments[4]).toEqual({ kind: 'text', text: 'Bitti.' }) + }) + + it('populates tool input from tool_input events before the result arrives', async () => { + vi.stubGlobal('$fetch', vi.fn().mockResolvedValue([])) + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(createStreamResponse([ + 'data: {"type":"tool_use","id":"t1","name":"save_content"}\n', + 'data: {"type":"tool_input","id":"t1","input":{"model":"faq","locale":"tr"}}\n', + ]))) + + const chat = useChat() + await chat.sendMessage('workspace-1', 'project-1', 'FAQ kaydet') + + const assistant = chat.messages.value[1]! + expect(assistant.segments[0]).toMatchObject({ + kind: 'tool', + call: { id: 't1', input: { model: 'faq', locale: 'tr' }, status: 'pending' }, + }) + }) + + it('backfills tool input from the tool_result event without clobbering an existing value', async () => { + vi.stubGlobal('$fetch', vi.fn().mockResolvedValue([])) + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(createStreamResponse([ + 'data: {"type":"tool_use","id":"t1","name":"save_content"}\n', + 'data: {"type":"tool_result","id":"t1","input":{"model":"faq"},"result":{"ok":true}}\n', + 'data: {"type":"tool_use","id":"t2","name":"brain_query"}\n', + 'data: {"type":"tool_input","id":"t2","input":{"model":"faq","entryId":"e1"}}\n', + 'data: {"type":"tool_result","id":"t2","result":{"data":null}}\n', + ]))) + + const chat = useChat() + await chat.sendMessage('workspace-1', 'project-1', 'FAQ kaydet') + + const assistant = chat.messages.value[1]! + expect(assistant.segments[0]).toMatchObject({ + kind: 'tool', + call: { id: 't1', input: { model: 'faq' }, status: 'complete' }, + }) + // t2's input came via tool_input; the input-less result must not erase it. + expect(assistant.segments[1]).toMatchObject({ + kind: 'tool', + call: { id: 't2', input: { model: 'faq', entryId: 'e1' }, status: 'complete' }, + }) + }) + + it('keeps partially streamed content when the user aborts mid-turn', async () => { + const abortError = Object.assign(new Error('aborted'), { name: 'AbortError' }) + vi.stubGlobal('$fetch', vi.fn().mockResolvedValue([])) + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(createStreamResponse([ + 'data: {"type":"text","content":"Başladım…"}\n', + ], { failAfter: abortError }))) + + const chat = useChat() + await chat.sendMessage('workspace-1', 'project-1', 'uzun iş') + + expect(chat.messages.value).toHaveLength(2) + expect(messageText(chat.messages.value[1]!)).toBe('Başladım…') + expect(chat.error.value).toBeNull() + }) + + it('bumps streamTick on content-bearing sse events', async () => { + vi.stubGlobal('$fetch', vi.fn().mockResolvedValue([])) + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(createStreamResponse([ + 'data: {"type":"conversation","id":"conv-1"}\n', + 'data: {"type":"text","content":"a"}\n', + 'data: {"type":"tool_use","id":"t1","name":"save_content"}\n', + 'data: {"type":"tool_input","id":"t1","input":{}}\n', + 'data: {"type":"tool_result","id":"t1","result":{"ok":true}}\n', + 'data: {"type":"done","affected":{"models":[],"locales":[],"snapshotChanged":false,"branchesChanged":false}}\n', + ]))) + + const chat = useChat() + await chat.sendMessage('workspace-1', 'project-1', 'test') + + // conversation + done don't move content — 4 content events tick. + expect(chat.streamTick.value).toBe(4) + }) + it('refreshes content from a tool result even when the stream ends without a done event', async () => { // A content-mutating tool result carries `affected`; the context panel // must refresh from it (debounced, flushed when the stream ends) instead diff --git a/tests/unit/conversation-engine-regression.test.ts b/tests/unit/conversation-engine-regression.test.ts index 6a047f8b..cbeae87c 100644 --- a/tests/unit/conversation-engine-regression.test.ts +++ b/tests/unit/conversation-engine-regression.test.ts @@ -198,6 +198,35 @@ describe('conversation engine regression', () => { }) }) + it('surfaces tool input on tool_input and tool_result events', async () => { + // The card UI shows what a tool is doing while it's pending: the + // engine emits `tool_input` as soon as the model finishes the call + // (before execution) and repeats the input on `tool_result` as the + // guaranteed carrier. + const { events } = await collectConversationEvents({ + maxToolIterations: 1, + aiProvider: { + streamCompletion: async function* () { + yield { type: 'tool_use_start', toolId: 'tool-1', toolName: 'test_tool' } + yield { type: 'tool_use_end', toolId: 'tool-1', toolName: 'test_tool', toolInput: { model: 'posts', locale: 'en' } } + yield { type: 'message_end', stopReason: 'tool_use', usage: { inputTokens: 5, outputTokens: 2 } } + }, + createCompletion: vi.fn(), + }, + }) + + expect(events).toContainEqual({ + type: 'tool_input', + id: 'tool-1', + input: { model: 'posts', locale: 'en' }, + }) + const toolResult = events.find(e => e.type === 'tool_result') + expect(toolResult).toMatchObject({ + id: 'tool-1', + input: { model: 'posts', locale: 'en' }, + }) + }) + it('preserves streamed interleaved text and tool_use block order', async () => { const { events, messages } = await collectConversationEvents({ maxToolIterations: 1,