-
+
-
-
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,