From 9f8b4336554046ebcd4dbdc1f408f8c401972e88 Mon Sep 17 00:00:00 2001 From: mfrachet Date: Mon, 29 Jun 2026 13:58:21 +0200 Subject: [PATCH 1/5] wip --- .changeset/thirty-candies-invite.md | 5 + mastracode/e2e/web/driver.ts | 38 +-- .../e2e/web/streaming-text.scenario.test.ts | 10 +- .../web/transcript-hydrate.scenario.test.ts | 74 +++-- mastracode/package.json | 9 +- mastracode/src/web/ui/App.tsx | 22 +- mastracode/src/web/ui/SettingsPanel.tsx | 3 +- .../__tests__/message-rendering.msw.test.tsx | 174 ++++++++++++ ...ent-controller-message-accumulator.test.ts | 118 ++++++++ .../agent-controller-message-accumulator.ts | 155 ++++++++++ mastracode/src/web/ui/components.tsx | 264 +++++++++++++----- mastracode/src/web/ui/transcript.test.ts | 160 +++++++++++ mastracode/src/web/ui/transcript.ts | 245 ++++++++-------- pnpm-lock.yaml | 91 +++--- 14 files changed, 1061 insertions(+), 307 deletions(-) create mode 100644 .changeset/thirty-candies-invite.md create mode 100644 mastracode/src/web/ui/__tests__/message-rendering.msw.test.tsx create mode 100644 mastracode/src/web/ui/agent-controller-message-accumulator.test.ts create mode 100644 mastracode/src/web/ui/agent-controller-message-accumulator.ts create mode 100644 mastracode/src/web/ui/transcript.test.ts diff --git a/.changeset/thirty-candies-invite.md b/.changeset/thirty-candies-invite.md new file mode 100644 index 00000000000..ae519f11303 --- /dev/null +++ b/.changeset/thirty-candies-invite.md @@ -0,0 +1,5 @@ +--- +'mastracode': patch +--- + +Improved MastraCode web chat message rendering to use standard Mastra message parts. diff --git a/mastracode/e2e/web/driver.ts b/mastracode/e2e/web/driver.ts index a281fab1493..8ca52103d59 100644 --- a/mastracode/e2e/web/driver.ts +++ b/mastracode/e2e/web/driver.ts @@ -216,34 +216,38 @@ export async function createDriver(opts: { function entryText(entry: TimelineEntry): string { switch (entry.kind) { - case 'user': - return entry.text; - case 'assistant': { - // Flatten the ordered segments to text, interleaving tool name/output in - // execution order — exactly how they render. + case 'message': { const parts: string[] = []; - for (const seg of entry.segments) { - if (seg.kind === 'text' || seg.kind === 'thinking') { - parts.push(seg.text); - } else { - const tool = entry.toolsById[seg.toolCallId]; - if (tool) parts.push(tool.toolName, tool.output); + for (const part of entry.message.content.parts) { + if (part.type === 'text') { + parts.push(part.text); + } else if (part.type === 'reasoning') { + parts.push(part.reasoning); + } else if (part.type === 'tool-invocation') { + const invocation = part.toolInvocation; + const runtimeTool = entry.runtimeTools?.[invocation.toolCallId]; + parts.push( + runtimeTool?.toolName ?? invocation.toolName, + runtimeTool?.output ?? '', + runtimeTool?.result === undefined ? '' : String(runtimeTool.result), + invocation.state === 'result' && invocation.result !== undefined ? String(invocation.result) : '', + ); } } - return parts.join(' '); + return parts.filter(Boolean).join(' '); } case 'notice': return entry.text; case 'approval': - return `approve ${(entry as ApprovalPrompt).toolName}`; + return `approve ${entry.toolName}`; case 'suspension': - return `suspend ${(entry as SuspensionPrompt).toolName}`; + return `suspend ${entry.toolName}`; case 'notification': - return `notification ${(entry as NotificationEntry).message}`; + return `notification ${entry.message}`; case 'notification_summary': - return `notification_summary ${(entry as { message: string }).message}`; + return `notification_summary ${entry.message}`; case 'subagent': - return `subagent ${(entry as SubagentEntry).agentType} ${(entry as SubagentEntry).task}`; + return `subagent ${entry.agentType} ${entry.task}`; default: return ''; } diff --git a/mastracode/e2e/web/streaming-text.scenario.test.ts b/mastracode/e2e/web/streaming-text.scenario.test.ts index 406249da84b..36486cf9889 100644 --- a/mastracode/e2e/web/streaming-text.scenario.test.ts +++ b/mastracode/e2e/web/streaming-text.scenario.test.ts @@ -17,13 +17,13 @@ const scenario: WebScenario = { // After message_end the streaming flag should be false. const state = driver.state(); - const assistantEntries = state.entries.filter(e => e.kind === 'assistant'); + const assistantEntries = state.entries.filter(e => e.kind === 'message' && e.message.role === 'assistant'); const last = assistantEntries[assistantEntries.length - 1]; - if (!last || last.kind !== 'assistant') throw new Error('No assistant entry found'); + if (!last || last.kind !== 'message') throw new Error('No assistant entry found'); if (last.streaming) throw new Error('Expected streaming=false after message_end, got true'); - const assistantText = last.segments - .filter(s => s.kind === 'text') - .map(s => (s.kind === 'text' ? s.text : '')) + const assistantText = last.message.content.parts + .filter(part => part.type === 'text') + .map(part => (part.type === 'text' ? part.text : '')) .join(''); if (!assistantText.includes('Streaming test response')) { throw new Error(`Unexpected text: ${assistantText}`); diff --git a/mastracode/e2e/web/transcript-hydrate.scenario.test.ts b/mastracode/e2e/web/transcript-hydrate.scenario.test.ts index 0f2dd31d44e..0027849d63d 100644 --- a/mastracode/e2e/web/transcript-hydrate.scenario.test.ts +++ b/mastracode/e2e/web/transcript-hydrate.scenario.test.ts @@ -2,12 +2,22 @@ import type { AgentControllerMessage } from '@mastra/client-js'; import { describe, it, expect } from 'vitest'; import { initialTranscript, transcriptReducer } from '../../src/web/ui/transcript.js'; -import type { TimelineEntry } from '../../src/web/ui/transcript.js'; +import type { MessageEntry, TimelineEntry } from '../../src/web/ui/transcript.js'; -/** Flatten an assistant entry's ordered text/thinking segments to a string. */ -function assistantText(entry: TimelineEntry): string { - if (entry.kind !== 'assistant') return ''; - return entry.segments.map(s => (s.kind === 'text' || s.kind === 'thinking' ? s.text : '')).join(''); +/** Flatten a message entry's ordered text/reasoning parts to a string. */ +function messageText(entry: TimelineEntry): string { + if (entry.kind !== 'message') return ''; + return entry.message.content.parts + .map(part => { + if (part.type === 'text') return part.text; + if (part.type === 'reasoning') return part.reasoning; + return ''; + }) + .join(''); +} + +function toolParts(entry: MessageEntry) { + return entry.message.content.parts.filter(part => part.type === 'tool-invocation'); } /** @@ -41,15 +51,17 @@ describe('transcript hydrate (thread history rendering)', () => { expect(state.modeId).toBe('build'); expect(state.modelId).toBe('openai/gpt-5.4-mini'); expect(state.entries).toHaveLength(2); - expect(state.entries[0]).toMatchObject({ kind: 'user', id: 'u1', text: 'hello there' }); - expect(state.entries[1]).toMatchObject({ kind: 'assistant', id: 'a1', streaming: false }); - expect(assistantText(state.entries[1])).toBe('hi, how can I help?'); + expect(state.entries[0]).toMatchObject({ kind: 'message', id: 'u1', message: { role: 'user' } }); + expect(messageText(state.entries[0])).toBe('hello there'); + expect(state.entries[1]).toMatchObject({ kind: 'message', id: 'a1', message: { role: 'assistant' }, streaming: false }); + expect(messageText(state.entries[1])).toBe('hi, how can I help?'); }); - it('omits system messages from the rendered transcript', () => { + it('keeps system messages in the hydrated message timeline', () => { const messages = [systemMsg('s1', 'you are a coding agent'), userMsg('u1', 'go')]; const state = transcriptReducer(initialTranscript, { type: 'hydrate', messages, threadId: 't' }); - expect(state.entries.map(e => e.kind)).toEqual(['user']); + expect(state.entries.map(e => (e.kind === 'message' ? e.message.role : e.kind))).toEqual(['system', 'user']); + expect(messageText(state.entries[0])).toBe('you are a coding agent'); }); it('replaces prior transcript contents (switching threads is a clean swap)', () => { @@ -69,7 +81,7 @@ describe('transcript hydrate (thread history rendering)', () => { }); expect(state.threadId).toBe('B'); expect(state.entries).toHaveLength(2); - const allText = state.entries.map(e => (e.kind === 'user' ? e.text : assistantText(e))).join('\n'); + const allText = state.entries.map(e => messageText(e)).join('\n'); expect(allText).toContain('thread B message'); expect(allText).not.toContain('thread A message'); }); @@ -91,17 +103,16 @@ describe('transcript hydrate (thread history rendering)', () => { threadId: 't', }); - const assistant = state.entries.find(e => e.kind === 'assistant'); + const assistant = state.entries.find(e => e.kind === 'message' && e.message.role === 'assistant'); expect(assistant).toBeDefined(); - if (assistant?.kind !== 'assistant') throw new Error('expected assistant entry'); - expect(assistantText(assistant)).toBe('Let me read that file.'); - const toolIds = Object.keys(assistant.toolsById); - expect(toolIds).toHaveLength(1); - expect(assistant.toolsById['tc-1']).toMatchObject({ + if (assistant?.kind !== 'message') throw new Error('expected assistant entry'); + expect(messageText(assistant)).toBe('Let me read that file.'); + const tools = toolParts(assistant); + expect(tools).toHaveLength(1); + expect(tools[0]?.toolInvocation).toMatchObject({ + state: 'result', toolCallId: 'tc-1', toolName: 'read_file', - args: { path: 'README.md' }, - status: 'done', result: 'file contents here', }); }); @@ -122,15 +133,13 @@ describe('transcript hydrate (thread history rendering)', () => { } as unknown as AgentControllerMessage; const state = transcriptReducer(initialTranscript, { type: 'hydrate', messages: [msg], threadId: 't' }); const assistant = state.entries[0]; - if (assistant.kind !== 'assistant') throw new Error('expected assistant entry'); - // The segment order must mirror content order, not bucket tools at the end. - expect(assistant.segments.map(s => (s.kind === 'tool' ? `tool:${s.toolCallId}` : s.kind))).toEqual([ - 'text', - 'tool:tc-1', - 'text', - 'tool:tc-2', - 'text', - ]); + if (assistant.kind !== 'message') throw new Error('expected assistant entry'); + // The part order must mirror content order, not bucket tools at the end. + expect( + assistant.message.content.parts.map(part => + part.type === 'tool-invocation' ? `tool:${part.toolInvocation.toolCallId}` : part.type, + ), + ).toEqual(['text', 'tool:tc-1', 'text', 'tool:tc-2', 'text']); }); it('marks a tool as errored when its result is an error', () => { @@ -144,8 +153,13 @@ describe('transcript hydrate (thread history rendering)', () => { } as unknown as AgentControllerMessage; const state = transcriptReducer(initialTranscript, { type: 'hydrate', messages: [msg], threadId: 't' }); const assistant = state.entries[0]; - if (assistant.kind !== 'assistant') throw new Error('expected assistant entry'); - expect(assistant.toolsById['tc-9'].status).toBe('error'); + if (assistant.kind !== 'message') throw new Error('expected assistant entry'); + const [tool] = toolParts(assistant); + expect(tool?.toolInvocation.state).toBe('result'); + expect(tool?.toolInvocation.result).toBe('command not found'); + expect(assistant.message.content.metadata?.harnessContent).toContainEqual( + expect.objectContaining({ type: 'tool_result', id: 'tc-9', isError: true }), + ); }); it('produces an empty transcript for a thread with no history', () => { diff --git a/mastracode/package.json b/mastracode/package.json index 9ab9dade76d..e29e4b09c95 100644 --- a/mastracode/package.json +++ b/mastracode/package.json @@ -95,6 +95,7 @@ "@mastra/memory": "workspace:*", "@mastra/observability": "workspace:*", "@mastra/pg": "workspace:*", + "@mastra/react": "workspace:*", "@mastra/schema-compat": "workspace:*", "@mastra/server": "workspace:*", "@mastra/stagehand": "workspace:*", @@ -125,8 +126,8 @@ "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.5.2", "@types/node": "22.19.21", - "@types/react": "^18.3.12", - "@types/react-dom": "^18.3.1", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^4.3.4", "@vitest/coverage-v8": "catalog:", "@vitest/ui": "catalog:", @@ -137,8 +138,8 @@ "jsdom": "^26.1.0", "marked": "^15.0.0", "msw": "^2.12.11", - "react": "^18.3.1", - "react-dom": "^18.3.1", + "react": "^19.2.5", + "react-dom": "^19.2.5", "tsup": "^8.5.1", "tsx": "catalog:", "typescript": "catalog:", diff --git a/mastracode/src/web/ui/App.tsx b/mastracode/src/web/ui/App.tsx index 85946773301..9c8164f3d55 100644 --- a/mastracode/src/web/ui/App.tsx +++ b/mastracode/src/web/ui/App.tsx @@ -1,6 +1,7 @@ import type { PlanResume } from '@mastra/client-js'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useApiConfig } from '../../shared/api/config'; import { CommandPalette } from './CommandPalette'; import { matchCommands, SLASH_COMMANDS } from './commands'; import type { SlashCommand } from './commands'; @@ -36,6 +37,8 @@ import { useAgentControllerSession } from './useAgentControllerSession'; export default function App() { const { toast } = useToast(); + const { baseUrl } = useApiConfig(); + // ── Projects (localStorage) ───────────────────────────────────────── const [projects, setProjects] = useState(() => loadProjects()); // Restore the last active project on reload (if it still exists), so the @@ -61,6 +64,7 @@ export default function App() { agentControllerId: 'code', resourceId, projectPath: activeProject?.path, + baseUrl, enabled: sessionEnabled, }); const { transcript, status, modes, threads, send, steer, abort, approveTool, respondSuspension } = session; @@ -148,11 +152,12 @@ export default function App() { // (not just when a whole new entry is appended). const lastTranscriptEntry = transcript.entries[transcript.entries.length - 1]; const streamingLen = - lastTranscriptEntry?.kind === 'assistant' - ? lastTranscriptEntry.segments.reduce( - (n, s) => (s.kind === 'text' || s.kind === 'thinking' ? n + s.text.length : n), - 0, - ) + lastTranscriptEntry?.kind === 'message' && lastTranscriptEntry.message.role === 'assistant' + ? lastTranscriptEntry.message.content.parts.reduce((n, part) => { + if (part.type === 'text') return n + part.text.length; + if (part.type === 'reasoning') return n + part.reasoning.length; + return n; + }, 0) : 0; // True when the user has scrolled up far enough that new content would land @@ -216,8 +221,11 @@ export default function App() { // streamed for the current turn. const lastEntry = transcript.entries[transcript.entries.length - 1]; const lastEntryHasText = - lastEntry?.kind === 'assistant' && lastEntry.segments.some(s => s.kind === 'text' && s.text.trim().length > 0); - const showWorkingIndicator = busy && !(lastEntry?.kind === 'assistant' && lastEntry.streaming && lastEntryHasText); + lastEntry?.kind === 'message' && + lastEntry.message.role === 'assistant' && + lastEntry.message.content.parts.some(part => part.type === 'text' && part.text.trim().length > 0); + const showWorkingIndicator = + busy && !(lastEntry?.kind === 'message' && lastEntry.message.role === 'assistant' && lastEntry.streaming && lastEntryHasText); // A restored active project from a pre-resourceId build won't have one yet; // backfill it so the session can connect. Runs once per project that needs it. diff --git a/mastracode/src/web/ui/SettingsPanel.tsx b/mastracode/src/web/ui/SettingsPanel.tsx index ab1538aaab2..aae36508bbb 100644 --- a/mastracode/src/web/ui/SettingsPanel.tsx +++ b/mastracode/src/web/ui/SettingsPanel.tsx @@ -6,6 +6,7 @@ import type { ToolCategory, } from '@mastra/client-js'; import { useEffect, useMemo, useRef, useState } from 'react'; +import type { ReactElement } from 'react'; import { CustomProvidersSection } from './CustomProvidersSection'; import { @@ -62,7 +63,7 @@ const NOTIFICATION_MODES: { value: NotificationMode; label: string }[] = [ { value: 'both', label: 'Both' }, ]; -const TABS: { id: Tab; label: string; icon: (p: { size?: number }) => JSX.Element }[] = [ +const TABS: { id: Tab; label: string; icon: (p: { size?: number }) => ReactElement }[] = [ { id: 'general', label: 'General', icon: PaletteIcon }, { id: 'model', label: 'Model', icon: SearchIcon }, { id: 'packs', label: 'Packs', icon: LayersIcon }, diff --git a/mastracode/src/web/ui/__tests__/message-rendering.msw.test.tsx b/mastracode/src/web/ui/__tests__/message-rendering.msw.test.tsx new file mode 100644 index 00000000000..9fbdc2cd2f0 --- /dev/null +++ b/mastracode/src/web/ui/__tests__/message-rendering.msw.test.tsx @@ -0,0 +1,174 @@ +import type { AgentControllerEvent, AgentControllerMessage, AgentControllerSessionState } from '@mastra/client-js'; +import { screen, within } from '@testing-library/react'; +import { http, HttpResponse } from 'msw'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { server } from '../../../../e2e/web-ui/msw-server'; +import { renderWithProviders, TEST_BASE_URL } from '../../../../e2e/web-ui/render'; +import App from '../App'; +import type { Project } from '../projects'; + +const API = `${TEST_BASE_URL}/api/agent-controller/code`; +const RESOURCE_ID = 'resource-test'; +const SESSION = `${API}/sessions/${RESOURCE_ID}`; +const THREAD_ID = 'thread-test'; +const PROJECT_PATH = '/tmp/mastracode-test'; + +function seedProject() { + const project: Project = { + id: 'project-test', + name: 'MastraCode Test', + path: PROJECT_PATH, + resourceId: RESOURCE_ID, + createdAt: 1, + }; + localStorage.setItem('mastracode-projects', JSON.stringify([project])); + localStorage.setItem('mastracode-active-project', project.id); +} + +function sessionState(): AgentControllerSessionState { + return { + controllerId: 'code', + resourceId: RESOURCE_ID, + modeId: 'build', + modelId: 'openai/gpt-4o-mini', + threadId: THREAD_ID, + settings: { yolo: false, thinkingLevel: 'medium', notifications: 'bell', smartEditing: true }, + }; +} + +function sse(events: AgentControllerEvent[] = []): Response { + const encoder = new TextEncoder(); + return new Response( + new ReadableStream({ + start(controller) { + for (const event of events) controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`)); + }, + cancel() {}, + }), + { headers: { 'content-type': 'text/event-stream' } }, + ); +} + +function delayedSse(event: AgentControllerEvent) { + const encoder = new TextEncoder(); + let emit: () => void = () => {}; + const response = new Response( + new ReadableStream({ + start(controller) { + emit = () => controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`)); + }, + cancel() {}, + }), + { headers: { 'content-type': 'text/event-stream' } }, + ); + return { response, emit }; +} + +function useAgentControllerHandlers({ + messages = [], + events = [], +}: { + messages?: AgentControllerMessage[]; + events?: AgentControllerEvent[]; +} = {}) { + server.use( + http.post(`${API}/sessions`, () => HttpResponse.json({ controllerId: 'code', resourceId: RESOURCE_ID, threadId: THREAD_ID })), + http.get(`${API}/modes`, () => HttpResponse.json({ modes: [{ id: 'build', label: 'Build' }] })), + http.get(`${API}/models`, () => HttpResponse.json({ models: [] })), + http.get(SESSION, () => HttpResponse.json(sessionState())), + http.put(`${SESSION}/state`, () => HttpResponse.json(sessionState())), + http.get(`${SESSION}/threads`, () => HttpResponse.json({ threads: [] })), + http.get(`${SESSION}/threads/${THREAD_ID}/messages`, () => HttpResponse.json({ messages })), + http.get(`${SESSION}/stream`, () => sse(events)), + ); +} + +afterEach(() => localStorage.clear()); + +describe('MastraCode message rendering', () => { + it('renders hydrated persisted text, thinking, and tool content through Mastra message parts', async () => { + seedProject(); + useAgentControllerHandlers({ + messages: [ + { + id: 'assistant-1', + role: 'assistant', + content: [ + { type: 'text', text: '**Hello** from hydrate' }, + { type: 'thinking', thinking: 'checking files' }, + { type: 'tool_call', id: 'tool-1', name: 'view', args: { path: 'README.md' } }, + { type: 'tool_result', id: 'tool-1', name: 'view', result: 'readme contents' }, + ], + }, + ], + }); + + renderWithProviders(); + + expect(await screen.findByText('Hello')).toBeInTheDocument(); + expect(screen.getByText('from hydrate')).toBeInTheDocument(); + expect(screen.getByText('checking files')).toBeInTheDocument(); + const toolName = screen.getAllByText('view').find(node => node.closest('.tool-card')); + if (!toolName) throw new Error('missing view tool card'); + const card = toolName.closest('.tool-card'); + if (!(card instanceof HTMLElement)) throw new Error('missing view tool card wrapper'); + expect(within(card).getByText('Done')).toBeInTheDocument(); + }); + + it('renders assistant text when SSE message updates arrive after subscription', async () => { + seedProject(); + const stream = delayedSse({ + type: 'message_update', + message: { id: 'assistant-stream', role: 'assistant', content: [{ type: 'text', text: 'Streaming now' }] }, + }); + useAgentControllerHandlers(); + server.use(http.get(`${SESSION}/stream`, () => stream.response)); + + renderWithProviders(); + + expect(await screen.findByText('ready')).toBeInTheDocument(); + stream.emit(); + + expect(await screen.findByText('Streaming now')).toBeInTheDocument(); + }); + + it('renders tool lifecycle events inline before a later message update re-emits the tool part', async () => { + seedProject(); + useAgentControllerHandlers({ + events: [ + { type: 'tool_input_start', toolCallId: 'tool-live', toolName: 'execute_command' }, + { type: 'tool_input_delta', toolCallId: 'tool-live', argsTextDelta: '{"command":"pnpm test"}', toolName: 'execute_command' }, + { type: 'tool_start', toolCallId: 'tool-live', toolName: 'execute_command', args: { command: 'pnpm test' } }, + { type: 'shell_output', toolCallId: 'tool-live', output: 'passing tests', stream: 'stdout' }, + { type: 'tool_end', toolCallId: 'tool-live', result: 'ok' }, + ], + }); + + renderWithProviders(); + + const toolName = await screen.findByText('execute_command'); + const card = toolName.closest('.tool-card'); + if (!(card instanceof HTMLElement)) throw new Error('missing tool card'); + expect(within(card).getByText('Done')).toBeInTheDocument(); + expect(within(card).getByText('passing tests')).toBeInTheDocument(); + }); + + it('renders status metadata as status UI instead of raw JSON', async () => { + seedProject(); + useAgentControllerHandlers({ + messages: [ + { + id: 'assistant-status', + role: 'assistant', + content: [{ type: 'om_thread_title_updated', text: 'Thread title updated: Better title' }], + }, + ], + }); + + renderWithProviders(); + + expect(await screen.findByText('Thread title updated: Better title')).toBeInTheDocument(); + expect(screen.queryByText(/om_thread_title_updated/)).not.toBeInTheDocument(); + }); +}); diff --git a/mastracode/src/web/ui/agent-controller-message-accumulator.test.ts b/mastracode/src/web/ui/agent-controller-message-accumulator.test.ts new file mode 100644 index 00000000000..84a9ee7cc7c --- /dev/null +++ b/mastracode/src/web/ui/agent-controller-message-accumulator.test.ts @@ -0,0 +1,118 @@ +import type { AgentControllerEvent, AgentControllerMessage } from '@mastra/client-js'; +import type { MastraDBMessage } from '@mastra/core/agent'; +import { describe, expect, it } from 'vitest'; + +import { accumulateMessages, toMastraDBMessage } from './agent-controller-message-accumulator'; + +describe('agent controller message accumulator', () => { + it('converts visible controller content into ordered Mastra message parts', () => { + const message: AgentControllerMessage = { + id: 'message-1', + role: 'assistant', + content: [ + { type: 'text', text: 'I will inspect the file.' }, + { type: 'thinking', thinking: 'Need to check the current implementation.' }, + { type: 'tool_call', id: 'tool-1', name: 'read_file', args: { path: 'src/index.ts' } }, + { type: 'tool_result', id: 'tool-1', name: 'read_file', result: 'export const value = 1;' }, + ], + }; + + const converted = toMastraDBMessage(message); + + expect(converted).toMatchObject({ + id: 'message-1', + role: 'assistant', + content: { format: 2 }, + }); + expect(converted.createdAt).toBeInstanceOf(Date); + expect(converted.content.parts).toEqual([ + { type: 'text', text: 'I will inspect the file.' }, + { + type: 'reasoning', + reasoning: 'Need to check the current implementation.', + details: [{ type: 'text', text: 'Need to check the current implementation.' }], + }, + { + type: 'tool-invocation', + toolInvocation: { + state: 'result', + toolCallId: 'tool-1', + toolName: 'read_file', + args: { path: 'src/index.ts' }, + result: 'export const value = 1;', + }, + }, + ]); + }); + + it('stores structured status content as harness metadata with readable fallback text', () => { + const message: AgentControllerMessage = { + id: 'status-1', + role: 'system', + content: [ + { type: 'notification_summary', text: 'Review pending notifications' }, + { type: 'om_thread_title_updated', text: 'Refactor transcript renderer' }, + ], + }; + + const converted = toMastraDBMessage(message); + + expect(converted.content.metadata?.harnessContent).toEqual(message.content); + expect(converted.content.parts).toEqual([ + { type: 'text', text: 'Review pending notifications' }, + { type: 'text', text: 'Thread title updated: Refactor transcript renderer' }, + ]); + }); + + it('upserts repeated message events by message id', () => { + const existingMessage = toMastraDBMessage({ + id: 'message-1', + role: 'assistant', + content: [{ type: 'text', text: 'Initial text' }], + }); + const event: AgentControllerEvent = { + type: 'message_update', + message: { + id: 'message-1', + role: 'assistant', + content: [{ type: 'text', text: 'Updated text' }], + }, + }; + + const accumulated = accumulateMessages([existingMessage], event); + + expect(accumulated).toHaveLength(1); + expect(accumulated[0]?.content.parts).toEqual([{ type: 'text', text: 'Updated text' }]); + expect(accumulated[0]?.createdAt).toBe(existingMessage.createdAt); + }); + + it('appends controller errors as synthetic system messages', () => { + const existingMessages: MastraDBMessage[] = []; + const event: AgentControllerEvent = { + type: 'error', + error: { message: 'The controller failed' }, + errorType: 'controller', + }; + + const accumulated = accumulateMessages(existingMessages, event); + + expect(accumulated).toHaveLength(1); + expect(accumulated[0]).toMatchObject({ role: 'system' }); + expect(accumulated[0]?.id).toMatch(/^error-/); + expect(accumulated[0]?.content.parts).toEqual([{ type: 'text', text: 'The controller failed' }]); + expect(accumulated[0]?.content.metadata?.harnessContent).toEqual([ + { type: 'harness-error', text: 'The controller failed', errorType: 'controller' }, + ]); + }); + + it('returns existing messages unchanged for unrelated events', () => { + const existingMessages: MastraDBMessage[] = [ + toMastraDBMessage({ id: 'message-1', role: 'assistant', content: [{ type: 'text', text: 'Keep me' }] }), + ]; + const event: AgentControllerEvent = { type: 'agent_start' }; + + const accumulated = accumulateMessages(existingMessages, event); + + expect(accumulated).toBe(existingMessages); + }); +}); diff --git a/mastracode/src/web/ui/agent-controller-message-accumulator.ts b/mastracode/src/web/ui/agent-controller-message-accumulator.ts new file mode 100644 index 00000000000..ea565db0fb8 --- /dev/null +++ b/mastracode/src/web/ui/agent-controller-message-accumulator.ts @@ -0,0 +1,155 @@ +import type { AgentControllerEvent, AgentControllerMessage, AgentControllerMessageContent } from '@mastra/client-js'; +import type { MastraDBMessage, MastraMessagePart } from '@mastra/core/agent'; + +const fallbackCreatedAtByMessageId = new Map(); + +export function accumulateMessages(messages: MastraDBMessage[], event: AgentControllerEvent): MastraDBMessage[] { + if (isMessageEvent(event)) { + const nextMessage = toMastraDBMessage(event.message); + const existingIndex = messages.findIndex(message => message.id === nextMessage.id); + + if (existingIndex === -1) { + return [...messages, nextMessage]; + } + + return messages.map((message, index) => (index === existingIndex ? nextMessage : message)); + } + + if (isErrorEvent(event)) { + return [...messages, toHarnessErrorMessage(event)]; + } + + return messages; +} + +export function toMastraDBMessage(message: AgentControllerMessage): MastraDBMessage { + const harnessContent = message.content.filter(isHarnessMetadataContent); + + return { + id: message.id, + role: message.role, + createdAt: createdAtForMessage(message.id), + content: { + format: 2, + parts: toMastraMessageParts(message.content), + ...(harnessContent.length > 0 ? { metadata: { harnessContent } } : {}), + }, + }; +} + +function toMastraMessageParts(content: AgentControllerMessageContent[]): MastraMessagePart[] { + const parts: MastraMessagePart[] = []; + const toolPartIndexById = new Map(); + + for (const part of content) { + switch (part.type) { + case 'text': + if (part.text) parts.push({ type: 'text', text: part.text }); + break; + case 'thinking': + if (part.thinking) { + parts.push({ + type: 'reasoning', + reasoning: part.thinking, + details: [{ type: 'text', text: part.thinking }], + }); + } + break; + case 'tool_call': { + const toolCallId = part.id ?? ''; + toolPartIndexById.set(toolCallId, parts.length); + parts.push({ + type: 'tool-invocation', + toolInvocation: { + state: 'call', + toolCallId, + toolName: part.name ?? '', + args: part.args, + }, + }); + break; + } + case 'tool_result': { + const toolCallId = part.id ?? ''; + const existingIndex = toolPartIndexById.get(toolCallId); + const previousPart = existingIndex === undefined ? undefined : parts[existingIndex]; + const previousInvocation = previousPart?.type === 'tool-invocation' ? previousPart.toolInvocation : undefined; + const resultPart: MastraMessagePart = { + type: 'tool-invocation', + toolInvocation: { + state: 'result', + toolCallId, + toolName: part.name ?? previousInvocation?.toolName ?? '', + args: previousInvocation?.args, + result: part.result, + }, + }; + + if (existingIndex === undefined) { + toolPartIndexById.set(toolCallId, parts.length); + parts.push(resultPart); + } else { + parts[existingIndex] = resultPart; + } + break; + } + default: { + const statusText = toStatusText(part); + if (statusText) parts.push({ type: 'text', text: statusText }); + break; + } + } + } + + return parts; +} + +function isHarnessMetadataContent(part: AgentControllerMessageContent): boolean { + return !['text', 'thinking', 'tool_call'].includes(part.type); +} + +function toStatusText(part: AgentControllerMessageContent): string | null { + if (part.type === 'om_thread_title_updated' && part.text) { + return `Thread title updated: ${part.text}`; + } + + return part.text ?? null; +} + +function isMessageEvent( + event: AgentControllerEvent, +): event is Extract { + return event.type === 'message_start' || event.type === 'message_update' || event.type === 'message_end'; +} + +function isErrorEvent(event: AgentControllerEvent): event is Extract { + return event.type === 'error' && 'error' in event; +} + +function toHarnessErrorMessage(event: Extract): MastraDBMessage { + const message = typeof event.error === 'string' ? event.error : event.error.message ?? 'Controller error'; + const id = `error-${Date.now()}-${Math.random().toString(36).slice(2)}`; + const harnessContent: AgentControllerMessageContent[] = [ + { type: 'harness-error', text: message, ...(event.errorType ? { errorType: event.errorType } : {}) }, + ]; + + return { + id, + role: 'system', + createdAt: createdAtForMessage(id), + content: { + format: 2, + parts: [{ type: 'text', text: message }], + metadata: { harnessContent }, + }, + }; +} + +function createdAtForMessage(messageId: string): Date { + const existing = fallbackCreatedAtByMessageId.get(messageId); + if (existing) return existing; + + const createdAt = new Date(); + fallbackCreatedAtByMessageId.set(messageId, createdAt); + return createdAt; +} diff --git a/mastracode/src/web/ui/components.tsx b/mastracode/src/web/ui/components.tsx index 1af3a58ca80..d15d6f3847d 100644 --- a/mastracode/src/web/ui/components.tsx +++ b/mastracode/src/web/ui/components.tsx @@ -1,5 +1,7 @@ import type { PlanResume, AgentControllerOMProgress } from '@mastra/client-js'; -import { memo, useEffect, useState } from 'react'; +import { MessageFactory } from '@mastra/react'; +import type { FilePart, MessageRoleRenderers, ReasoningPart, TextPart, ToolInvocationPart } from '@mastra/react'; +import { memo, useEffect, useMemo, useState } from 'react'; import { highlightCode, languageForPath } from './highlight'; import { BellIcon, BrainIcon, ChevronIcon, CopyIcon, FolderIcon, LogoMark, TargetIcon, ToolIcon } from './icons'; @@ -8,8 +10,8 @@ import { useToast } from './toast'; import type { ApprovalPrompt, - AssistantEntry, GoalSnapshot, + MessageEntry, NoticeEntry, NotificationEntry, NotificationSummaryEntry, @@ -17,7 +19,6 @@ import type { SuspensionPrompt, TimelineEntry, ToolCall, - UserEntry, OMPhase, } from './transcript'; @@ -141,13 +142,26 @@ interface EditArgs { content?: string; } +function hasProperty(value: object, key: K): value is object & Record { + return key in value; +} + +function stringProperty(value: unknown, key: string): string | undefined { + if (!value || typeof value !== 'object' || !hasProperty(value, key)) return undefined; + return typeof value[key] === 'string' ? value[key] : undefined; +} + /** Detect edit-style tools whose args are better shown as a diff/code block. */ function editArgs(toolName: string, args: unknown): EditArgs | undefined { - if (!args || typeof args !== 'object') return undefined; - const a = args as EditArgs; - const isReplace = /string_replace|str_replace/i.test(toolName) && typeof a.new_string === 'string'; - const isWrite = /write_file|create_file/i.test(toolName) && typeof a.content === 'string'; - return isReplace || isWrite ? a : undefined; + const edit = { + path: stringProperty(args, 'path'), + old_string: stringProperty(args, 'old_string'), + new_string: stringProperty(args, 'new_string'), + content: stringProperty(args, 'content'), + }; + const isReplace = /string_replace|str_replace/i.test(toolName) && edit.new_string !== undefined; + const isWrite = /write_file|create_file/i.test(toolName) && edit.content !== undefined; + return isReplace || isWrite ? edit : undefined; } function ToolCard({ tool, forceExpanded }: { tool: ToolCall; forceExpanded?: boolean }) { @@ -277,6 +291,25 @@ interface SuspendPayloadShape { title?: string; } +function suspensionPayloadShape(payload: unknown): SuspendPayloadShape { + const planValue = payload && typeof payload === 'object' && hasProperty(payload, 'plan') ? payload.plan : undefined; + const plan = + planValue && typeof planValue === 'object' + ? { + title: stringProperty(planValue, 'title'), + summary: stringProperty(planValue, 'summary'), + } + : undefined; + + return { + question: stringProperty(payload, 'question'), + requestedPath: stringProperty(payload, 'requestedPath'), + reason: stringProperty(payload, 'reason'), + title: stringProperty(payload, 'title'), + plan, + }; +} + function SuspensionCard({ prompt, onRespond, @@ -284,7 +317,7 @@ function SuspensionCard({ prompt: SuspensionPrompt; onRespond: (toolCallId: string, resumeData: string | string[] | PlanResume, promptId: string) => void; }) { - const payload = (prompt.suspendPayload ?? {}) as SuspendPayloadShape; + const payload = suspensionPayloadShape(prompt.suspendPayload); if (prompt.toolName === 'submit_plan') { return ( @@ -465,10 +498,8 @@ export const Transcript = memo(function Transcript({ <> {entries.map(entry => { switch (entry.kind) { - case 'user': - return ; - case 'assistant': - return ; + case 'message': + return ; case 'notice': return ; case 'approval': @@ -489,78 +520,157 @@ export const Transcript = memo(function Transcript({ ); }); -function UserBubble({ entry }: { entry: UserEntry }) { - return ( -
-
- {entry.steer ? 'Steer' : 'You'} -
-
-
{entry.text}
-
-
- ); -} - -function AssistantBubble({ entry }: { entry: AssistantEntry }) { +function MessageBubble({ entry }: { entry: MessageEntry }) { // null = no group override; true/false = expand/collapse all in this bubble. const [allExpanded, setAllExpanded] = useState(undefined); + const parts = entry.message.content.parts ?? []; + const toolCount = parts.reduce((n, part) => (part.type === 'tool-invocation' ? n + 1 : n), 0); + const hasRenderablePart = parts.some( + part => + (part.type === 'text' && part.text.trim().length > 0) || + (part.type === 'reasoning' && part.reasoning.trim().length > 0) || + part.type === 'tool-invocation' || + part.type === 'file', + ); - const toolCount = entry.segments.reduce((n, s) => (s.kind === 'tool' ? n + 1 : n), 0); - const hasText = entry.segments.some(s => s.kind === 'text' && s.text.trim().length > 0); - if (!hasText && toolCount === 0) return null; + const status = statusMetadata(entry); + if (status) return ; + if (entry.message.role === 'assistant' && !hasRenderablePart) return null; - // The streaming cursor trails the final text segment while the model is still - // generating (so it sits at the live insertion point, not after a tool card). - const lastTextIdx = (() => { - for (let i = entry.segments.length - 1; i >= 0; i--) { - if (entry.segments[i].kind === 'text') return i; + const lastTextPart = (() => { + for (let i = parts.length - 1; i >= 0; i--) { + if (parts[i].type === 'text') return parts[i]; } - return -1; + return undefined; })(); - return ( -
-
- - - - Agent - {toolCount > 1 && ( - - )} -
-
- {entry.segments.map((seg, i) => { - if (seg.kind === 'text') { - return ( -
- {seg.text} - {entry.streaming && i === lastTextIdx && } -
- ); - } - if (seg.kind === 'thinking') { - return ( -
- {seg.text} -
- ); - } - const tool = entry.toolsById[seg.toolCallId]; - if (!tool) return null; - return ; - })} -
-
+ const roles = useMemo( + () => ({ + User: ({ children }) => ( +
+
+ {entry.steer ? 'Steer' : 'You'} +
+
{children}
+
+ ), + Assistant: ({ children }) => ( +
+
+ + + + Agent + {toolCount > 1 && ( + + )} +
+
{children}
+
+ ), + System: ({ children }) => ( +
+
+ System +
+
{children}
+
+ ), + Signal: ({ children }) => ( +
+
+ Signal +
+
{children}
+
+ ), + }), + [allExpanded, entry.steer, toolCount], + ); + + const renderers = useMemo( + () => ({ + Text: (part: TextPart) => + entry.message.role === 'user' ? ( +
{part.text}
+ ) : ( +
+ {part.text} + {entry.streaming && part === lastTextPart && } +
+ ), + Reasoning: (part: ReasoningPart) => ( +
+ {part.reasoning} +
+ ), + ToolInvocation: (part: ToolInvocationPart) => { + const runtime = entry.runtimeTools?.[part.toolInvocation.toolCallId]; + const tool = toolFromInvocationPart(part, runtime); + return ; + }, + File: (part: FilePart) =>
{stringify(part)}
, + }), + [allExpanded, entry.message.role, entry.runtimeTools, entry.streaming, lastTextPart], ); + + return null} />; +} + +function toolFromInvocationPart(part: ToolInvocationPart, runtime?: ToolCall): ToolCall { + const invocation = part.toolInvocation; + return { + toolCallId: invocation.toolCallId, + toolName: invocation.toolName, + argsText: runtime?.argsText ?? '', + args: runtime?.args ?? ('args' in invocation ? invocation.args : undefined), + status: runtime?.status ?? (invocation.state === 'result' ? 'done' : 'running'), + result: runtime?.result ?? ('result' in invocation ? invocation.result : undefined), + output: runtime?.output ?? '', + }; +} + +interface StatusMetadata { + id: string; + text: string; + level: 'info' | 'error'; +} + +function statusMetadata(entry: MessageEntry): StatusMetadata | undefined { + const harnessContent = entry.message.content.metadata?.harnessContent; + if (!Array.isArray(harnessContent)) return undefined; + + const statusPart = harnessContent.find( + part => + typeof part === 'object' && + part !== null && + 'type' in part && + typeof part.type === 'string' && + (part.type === 'notification_summary' || part.type.startsWith('om_') || part.type === 'harness-error'), + ); + if (!statusPart || typeof statusPart !== 'object' || !('type' in statusPart)) return undefined; + + const text = 'text' in statusPart && typeof statusPart.text === 'string' ? statusPart.text : messageText(entry); + return { + id: `${entry.id}-${String(statusPart.type)}`, + text, + level: statusPart.type === 'harness-error' ? 'error' : 'info', + }; +} + +function messageText(entry: MessageEntry): string { + return entry.message.content.parts.flatMap(part => (part.type === 'text' ? [part.text] : [])).join(''); +} + +function StatusMetadataCard({ status }: { status: StatusMetadata }) { + return
{status.text}
; } function Notice({ entry }: { entry: NoticeEntry }) { diff --git a/mastracode/src/web/ui/transcript.test.ts b/mastracode/src/web/ui/transcript.test.ts new file mode 100644 index 00000000000..a0f5b67f11f --- /dev/null +++ b/mastracode/src/web/ui/transcript.test.ts @@ -0,0 +1,160 @@ +import type { AgentControllerMessage } from '@mastra/client-js'; +import { describe, expect, it } from 'vitest'; + +import { initialTranscript, transcriptReducer } from './transcript'; + +type MessageEntryFixture = { + kind: 'message'; + message: { content: { parts: unknown[] } }; +}; + +function messageParts(entry: unknown): unknown[] { + return isMessageEntry(entry) ? entry.message.content.parts : []; +} + +function isMessageEntry(entry: unknown): entry is MessageEntryFixture { + return typeof entry === 'object' && entry !== null && 'kind' in entry && entry.kind === 'message' && 'message' in entry; +} + +describe('transcript reducer message entries', () => { + it('hydrates controller messages as ordered MastraDBMessage entries', () => { + const messages: AgentControllerMessage[] = [ + { id: 'user-1', role: 'user', content: [{ type: 'text', text: 'Inspect this' }] }, + { + id: 'assistant-1', + role: 'assistant', + content: [ + { type: 'text', text: 'I will inspect it.' }, + { type: 'thinking', thinking: 'Need the file first.' }, + { type: 'tool_call', id: 'tool-1', name: 'view', args: { path: 'src/index.ts' } }, + { type: 'tool_result', id: 'tool-1', name: 'view', result: 'export const value = 1;' }, + ], + }, + ]; + + const state = transcriptReducer(initialTranscript, { type: 'hydrate', messages }); + + expect(state.entries).toHaveLength(2); + expect(state.entries[0]).toMatchObject({ + kind: 'message', + id: 'user-1', + message: { role: 'user', content: { format: 2, parts: [{ type: 'text', text: 'Inspect this' }] } }, + }); + expect(state.entries[1]).toMatchObject({ kind: 'message', id: 'assistant-1', streaming: false }); + expect(messageParts(state.entries[1])).toEqual([ + { type: 'text', text: 'I will inspect it.' }, + { type: 'reasoning', reasoning: 'Need the file first.', details: [{ type: 'text', text: 'Need the file first.' }] }, + { + type: 'tool-invocation', + toolInvocation: { + state: 'result', + toolCallId: 'tool-1', + toolName: 'view', + args: { path: 'src/index.ts' }, + result: 'export const value = 1;', + }, + }, + ]); + }); + + it('streams message updates without replacing non-message transcript state', () => { + const withNotice = transcriptReducer(initialTranscript, { type: 'localNotice', level: 'info', text: 'Command handled' }); + + const state = transcriptReducer(withNotice, { + type: 'event', + event: { + type: 'message_update', + message: { id: 'assistant-1', role: 'assistant', content: [{ type: 'text', text: 'Streaming text' }] }, + }, + }); + + expect(state.pending).toBe(false); + expect(state.entries[0]).toMatchObject({ kind: 'notice', text: 'Command handled' }); + expect(state.entries[1]).toMatchObject({ kind: 'message', id: 'assistant-1', streaming: true }); + expect(messageParts(state.entries[1])).toEqual([ + { type: 'text', text: 'Streaming text' }, + ]); + }); + + it('keeps tool lifecycle events visible inline before a message update re-emits the tool call', () => { + const started = transcriptReducer(initialTranscript, { + type: 'event', + event: { type: 'tool_start', toolCallId: 'tool-1', toolName: 'view', args: { path: 'src/index.ts' } }, + }); + + expect(messageParts(started.entries[0])).toEqual([ + { + type: 'tool-invocation', + toolInvocation: { + state: 'call', + toolCallId: 'tool-1', + toolName: 'view', + args: { path: 'src/index.ts' }, + }, + }, + ]); + + const ended = transcriptReducer(started, { + type: 'event', + event: { type: 'tool_end', toolCallId: 'tool-1', result: 'done', isError: false }, + }); + + expect(messageParts(ended.entries[0])).toEqual([ + { + type: 'tool-invocation', + toolInvocation: { + state: 'result', + toolCallId: 'tool-1', + toolName: 'view', + args: { path: 'src/index.ts' }, + result: 'done', + }, + }, + ]); + }); + + it('preserves non-message state while using message entries', () => { + const withTask = transcriptReducer(initialTranscript, { + type: 'event', + event: { + type: 'task_updated', + tasks: [{ id: 'task-1', content: 'Refactor transcript', status: 'in_progress', activeForm: 'Refactoring transcript' }], + }, + }); + const state = transcriptReducer(withTask, { + type: 'event', + event: { + type: 'display_state_changed', + displayState: { + tokenUsage: { totalTokens: 42 }, + omProgress: { msgTokens: 10, maxMsgTokens: 100, memTokens: 5, maxMemTokens: 50 }, + }, + }, + }); + const withSummary = transcriptReducer(state, { + type: 'event', + event: { + type: 'notification_summary', + message: '2 pending notifications', + pending: 2, + bySource: { agent: 2 }, + byPriority: { medium: 2 }, + notificationIds: ['n1', 'n2'], + }, + }); + const withApproval = transcriptReducer(withSummary, { + type: 'event', + event: { type: 'tool_approval_required', toolCallId: 'tool-1', toolName: 'edit', args: { path: 'src/index.ts' } }, + }); + + expect(withApproval.tasks).toEqual([ + { id: 'task-1', content: 'Refactor transcript', status: 'in_progress', activeForm: 'Refactoring transcript' }, + ]); + expect(withApproval.usage).toEqual({ totalTokens: 42 }); + expect(withApproval.omProgress).toEqual({ msgTokens: 10, maxMsgTokens: 100, memTokens: 5, maxMemTokens: 50 }); + expect(withApproval.entries).toEqual([ + expect.objectContaining({ kind: 'notification_summary', pending: 2 }), + expect.objectContaining({ kind: 'approval', toolCallId: 'tool-1' }), + ]); + }); +}); diff --git a/mastracode/src/web/ui/transcript.ts b/mastracode/src/web/ui/transcript.ts index 7e5e11c55e9..5364abae2a4 100644 --- a/mastracode/src/web/ui/transcript.ts +++ b/mastracode/src/web/ui/transcript.ts @@ -1,4 +1,3 @@ -import { agentControllerMessageText } from '@mastra/client-js'; import type { AgentControllerEvent, KnownAgentControllerEvent, @@ -6,6 +5,9 @@ import type { AgentControllerTaskSnapshot, AgentControllerOMProgress, } from '@mastra/client-js'; +import type { MastraDBMessage, MastraMessagePart } from '@mastra/core/agent'; + +import { toMastraDBMessage } from './agent-controller-message-accumulator'; /** * Transcript model + reducer. @@ -38,26 +40,14 @@ export interface ToolCall { * status/result, which arrives on separate tool_* events) lives in the entry's * `toolsById` map and is resolved at render time. */ -export type AssistantSegment = - | { kind: 'text'; text: string } - | { kind: 'thinking'; text: string } - | { kind: 'tool'; toolCallId: string }; - -export interface AssistantEntry { - kind: 'assistant'; +export interface MessageEntry { + kind: 'message'; id: string; - /** Ordered text / thinking / tool segments, in execution order. */ - segments: AssistantSegment[]; - /** Live tool state keyed by tool-call id, referenced by tool segments. */ - toolsById: Record; + message: MastraDBMessage; + /** Live tool state from tool_* events, overlaid by toolCallId without changing persisted message parts. */ + runtimeTools?: Record; /** True while the model is still generating tokens for this message. */ - streaming: boolean; -} - -export interface UserEntry { - kind: 'user'; - id: string; - text: string; + streaming?: boolean; /** A steer (interjection) vs a normal message. */ steer?: boolean; } @@ -123,8 +113,7 @@ export interface SubagentEntry { export type PromptEntry = ApprovalPrompt | SuspensionPrompt; export type TimelineEntry = - | UserEntry - | AssistantEntry + | MessageEntry | NoticeEntry | PromptEntry | NotificationEntry @@ -248,7 +237,14 @@ export function transcriptReducer(state: TranscriptState, action: Action): Trans pending: true, entries: [ ...state.entries, - { kind: 'user', id: `local-${Date.now()}-${noticeSeq++}`, text: action.text, steer: action.steer }, + toMessageEntry( + toMastraDBMessage({ + id: `local-${Date.now()}-${noticeSeq++}`, + role: 'user', + content: [{ type: 'text', text: action.text }], + }), + { steer: action.steer }, + ), ], }; case 'localNotice': @@ -530,103 +526,69 @@ function hydrate( omProgress?: AgentControllerOMProgress, usage?: UsageSnapshot, ): TranscriptState { - const entries: TimelineEntry[] = []; - for (const message of messages) { - if (message.role === 'user') { - entries.push({ kind: 'user', id: message.id, text: agentControllerMessageText(message) }); - } else if (message.role === 'assistant') { - const { segments, toolsById } = buildSegments(message); - entries.push({ kind: 'assistant', id: message.id, segments, toolsById, streaming: false }); - } - // 'system' messages aren't shown in the transcript. - } + const entries = messages.map(message => toMessageEntry(toMastraDBMessage(message), { streaming: false })); return { ...initialTranscript, entries, modeId, modelId, threadId, omProgress, usage }; } -/** - * Walk a message's content parts in order and produce ordered segments plus the - * tool state they reference. `prevTools` carries forward live tool runtime - * (streamed argsText / shell output / status) captured from tool_* events, - * which the persisted content parts don't include. - * - * This mirrors the TUI's `AssistantMessageComponent`, which renders each - * content part where it appears instead of concatenating text and grouping - * tools. - */ -function buildSegments( - message: AgentControllerMessage, - prevTools: Record = {}, -): { segments: AssistantSegment[]; toolsById: Record } { - const segments: AssistantSegment[] = []; - const toolsById: Record = {}; - let toolSeq = 0; - for (const part of message.content) { - if (part.type === 'text' && typeof part.text === 'string') { - if (part.text.length > 0) segments.push({ kind: 'text', text: part.text }); - } else if (part.type === 'thinking' && typeof part.thinking === 'string') { - if (part.thinking.trim().length > 0) segments.push({ kind: 'thinking', text: part.thinking }); - } else if (part.type === 'tool_call') { - const toolCallId = part.id ?? `${message.id}-tool-${toolSeq++}`; - const result = message.content.find(c => c.type === 'tool_result' && c.id === part.id); - const prev = prevTools[toolCallId]; - toolsById[toolCallId] = { - toolCallId, - toolName: part.name ?? prev?.toolName ?? 'tool', - // Keep streamed args text; fall back to nothing. - argsText: prev?.argsText ?? '', - args: part.args ?? prev?.args, - // A present tool_result means the call resolved; otherwise keep the - // live status (running) seeded from tool_* events. - status: result ? (result.isError ? 'error' : 'done') : (prev?.status ?? 'running'), - result: result?.result ?? prev?.result, - output: prev?.output ?? '', - }; - segments.push({ kind: 'tool', toolCallId }); - } - // 'tool_result' parts are folded into their tool_call above. - } - return { segments, toolsById }; +function toMessageEntry( + message: MastraDBMessage, + options: { streaming?: boolean; steer?: boolean; runtimeTools?: Record } = {}, +): MessageEntry { + return { + kind: 'message', + id: message.id, + message, + runtimeTools: options.runtimeTools, + streaming: options.streaming, + steer: options.steer, + }; } function upsertAssistant(state: TranscriptState, message: AgentControllerMessage, streaming: boolean): TranscriptState { if (message.role !== 'assistant') return state; const entries = [...state.entries]; - const idx = entries.findIndex(e => e.kind === 'assistant' && e.id === message.id); - const prev = idx !== -1 ? (entries[idx] as AssistantEntry) : undefined; - const { segments, toolsById } = buildSegments(message, prev?.toolsById); - - // Preserve any tools (and their segments) that arrived via tool_* events but - // aren't yet reflected in the streamed content — keeps a tool visible the - // instant it starts, before the next message_update lands. - if (prev) { - for (const seg of prev.segments) { - if (seg.kind === 'tool' && !toolsById[seg.toolCallId]) { - segments.push(seg); - const carried = prev.toolsById[seg.toolCallId]; - if (carried) toolsById[seg.toolCallId] = carried; - } - } - } + const idx = entries.findIndex(e => e.kind === 'message' && e.message.role === 'assistant' && e.id === message.id); + const prev = idx !== -1 ? entries[idx] : undefined; + const prevEntry = prev?.kind === 'message' ? prev : undefined; + const nextMessage = preserveRuntimeToolParts(toMastraDBMessage(message), prevEntry?.message); + const entry = toMessageEntry(nextMessage, { streaming, runtimeTools: prevEntry?.runtimeTools }); - const entry: AssistantEntry = { kind: 'assistant', id: message.id, segments, toolsById, streaming }; if (idx === -1) entries.push(entry); else entries[idx] = entry; return { ...state, entries }; } +function preserveRuntimeToolParts(message: MastraDBMessage, previous?: MastraDBMessage): MastraDBMessage { + if (!previous) return message; + + const parts = [...message.content.parts]; + const existingToolIds = new Set(parts.map(toolCallIdForPart).filter((id): id is string => Boolean(id))); + + for (const part of previous.content.parts) { + const toolCallId = toolCallIdForPart(part); + if (toolCallId && !existingToolIds.has(toolCallId)) { + parts.push(part); + existingToolIds.add(toolCallId); + } + } + + return { ...message, content: { ...message.content, parts } }; +} + /** True when the most recent assistant entry has any visible text. */ function hasAssistantText(state: TranscriptState): boolean { const idx = latestAssistantIndex(state.entries); if (idx === -1) return false; const entry = state.entries[idx]; - if (entry.kind !== 'assistant') return false; - return entry.segments.some(s => s.kind === 'text' && s.text.trim().length > 0); + if (entry.kind !== 'message') return false; + return entry.message.content.parts.some(part => part.type === 'text' && 'text' in part && part.text.trim().length > 0); } /** Find the latest assistant entry, creating one if none exists. */ function latestAssistantIndex(entries: TimelineEntry[]): number { for (let i = entries.length - 1; i >= 0; i--) { - if (entries[i].kind === 'assistant') return i; + const entry = entries[i]; + if (entry.kind === 'message' && entry.message.role === 'assistant') return i; } return -1; } @@ -640,36 +602,87 @@ function withTool( const entries = [...state.entries]; let idx = latestAssistantIndex(entries); if (idx === -1) { - entries.push({ - kind: 'assistant', + const message = toMastraDBMessage({ id: `assistant-tools-${Date.now()}`, - segments: [], - toolsById: {}, - streaming: false, + role: 'assistant', + content: [], }); + entries.push(toMessageEntry(message, { streaming: false })); idx = entries.length - 1; } - const assistant = entries[idx] as AssistantEntry; - const toolsById = { ...assistant.toolsById }; - const existing = toolsById[toolCallId] ?? { - toolCallId, - toolName: seed?.toolName ?? 'tool', + + const entry = entries[idx]; + if (entry.kind !== 'message') return state; + + const parts = [...entry.message.content.parts]; + const runtimeTools = { ...(entry.runtimeTools ?? {}) }; + const existing = runtimeTools[toolCallId] ?? toolCallFromPart(parts.find(part => toolCallIdForPart(part) === toolCallId)); + const tool = update( + existing ?? { + toolCallId, + toolName: seed?.toolName ?? 'tool', + argsText: '', + args: seed?.args, + status: 'running', + output: '', + }, + ); + runtimeTools[toolCallId] = tool; + + const partIndex = parts.findIndex(part => toolCallIdForPart(part) === toolCallId); + if (partIndex === -1) parts.push(toolPart(tool)); + else parts[partIndex] = toolPart(tool); + + entries[idx] = { + ...entry, + runtimeTools, + message: { ...entry.message, content: { ...entry.message.content, parts } }, + }; + return { ...state, entries }; +} + +function toolCallIdForPart(part: MastraMessagePart): string | undefined { + if (part.type !== 'tool-invocation') return undefined; + return part.toolInvocation.toolCallId; +} + +function toolCallFromPart(part: MastraMessagePart | undefined): ToolCall | undefined { + if (!part || part.type !== 'tool-invocation') return undefined; + const invocation = part.toolInvocation; + return { + toolCallId: invocation.toolCallId, + toolName: invocation.toolName, argsText: '', - args: seed?.args, - status: 'running' as const, + args: 'args' in invocation ? invocation.args : undefined, + status: invocation.state === 'result' ? 'done' : 'running', + result: 'result' in invocation ? invocation.result : undefined, output: '', }; - toolsById[toolCallId] = update(existing); +} - // Ensure a tool segment exists in execution order. A tool's first event can - // arrive before the message_update that would place it from content, so we - // append the segment here to keep it inline at the point it started. - const segments = assistant.segments.some(s => s.kind === 'tool' && s.toolCallId === toolCallId) - ? assistant.segments - : [...assistant.segments, { kind: 'tool' as const, toolCallId }]; +function toolPart(tool: ToolCall): MastraMessagePart { + if (tool.status === 'running') { + return { + type: 'tool-invocation', + toolInvocation: { + state: 'call', + toolCallId: tool.toolCallId, + toolName: tool.toolName, + args: tool.args, + }, + }; + } - entries[idx] = { ...assistant, segments, toolsById }; - return { ...state, entries }; + return { + type: 'tool-invocation', + toolInvocation: { + state: 'result', + toolCallId: tool.toolCallId, + toolName: tool.toolName, + args: tool.args, + result: tool.result, + }, + }; } function pushPrompt(state: TranscriptState, prompt: PromptEntry): TranscriptState { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 05fdd0d23f1..53e4d157f5c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1858,16 +1858,16 @@ importers: dependencies: '@ai-sdk/google': specifier: ^2.0.62 - version: 2.0.72(@opentelemetry/api@1.9.1)(@types/node@22.19.15)(@vitest/coverage-v8@4.1.8)(@vitest/ui@4.1.8)(jsdom@27.4.0(@noble/hashes@2.2.0)(bufferutil@4.1.0))(typescript@6.0.3)(vite@7.3.5(@types/node@22.19.15)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.22.4)(yaml@2.9.0))(zod@3.25.76) + version: 2.0.72(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/coverage-v8@4.1.8)(@vitest/ui@4.1.8)(jsdom@27.4.0(@noble/hashes@2.2.0)(bufferutil@4.1.0))(typescript@6.0.3)(vite@7.3.5(@types/node@22.19.15)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.22.4)(yaml@2.9.0))(zod@3.25.76) '@ai-sdk/openai': specifier: ^2.0.99 - version: 2.0.106(@opentelemetry/api@1.9.1)(@types/node@22.19.15)(@vitest/coverage-v8@4.1.8)(@vitest/ui@4.1.8)(jsdom@27.4.0(@noble/hashes@2.2.0)(bufferutil@4.1.0))(typescript@6.0.3)(vite@7.3.5(@types/node@22.19.15)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.22.4)(yaml@2.9.0))(zod@3.25.76) + version: 2.0.106(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/coverage-v8@4.1.8)(@vitest/ui@4.1.8)(jsdom@27.4.0(@noble/hashes@2.2.0)(bufferutil@4.1.0))(typescript@6.0.3)(vite@7.3.5(@types/node@22.19.15)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.22.4)(yaml@2.9.0))(zod@3.25.76) '@ai-sdk/provider': specifier: ^2.0.1 version: 2.0.3 '@ai-sdk/provider-utils': specifier: ^3.0.22 - version: 3.0.25(@opentelemetry/api@1.9.1)(@types/node@22.19.15)(@vitest/coverage-v8@4.1.8)(@vitest/ui@4.1.8)(jsdom@27.4.0(@noble/hashes@2.2.0)(bufferutil@4.1.0))(typescript@6.0.3)(vite@7.3.5(@types/node@22.19.15)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.22.4)(yaml@2.9.0))(zod@3.25.76) + version: 3.0.25(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/coverage-v8@4.1.8)(@vitest/ui@4.1.8)(jsdom@27.4.0(@noble/hashes@2.2.0)(bufferutil@4.1.0))(typescript@6.0.3)(vite@7.3.5(@types/node@22.19.15)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.22.4)(yaml@2.9.0))(zod@3.25.76) '@huggingface/hub': specifier: ^0.15.2 version: 0.15.2 @@ -1940,7 +1940,7 @@ importers: version: 6.0.3 vitest: specifier: 'catalog:' - version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@22.19.15)(@vitest/coverage-v8@4.1.8)(@vitest/ui@4.1.8)(jsdom@27.4.0(@noble/hashes@2.2.0)(bufferutil@4.1.0))(msw@2.14.6(@types/node@22.19.15)(typescript@6.0.3))(vite@7.3.5(@types/node@22.19.15)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/coverage-v8@4.1.8)(@vitest/ui@4.1.8)(jsdom@27.4.0(@noble/hashes@2.2.0)(bufferutil@4.1.0))(msw@2.14.6(@types/node@22.19.15)(typescript@6.0.3))(vite@7.3.5(@types/node@22.19.15)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.22.4)(yaml@2.9.0)) integrations/brightdata: devDependencies: @@ -2108,6 +2108,9 @@ importers: '@mastra/pg': specifier: workspace:* version: link:../stores/pg + '@mastra/react': + specifier: workspace:* + version: link:../client-sdks/react '@mastra/schema-compat': specifier: workspace:* version: link:../packages/schema-compat @@ -2122,7 +2125,7 @@ importers: version: link:../integrations/tavily '@tanstack/react-query': specifier: ^5.90.21 - version: 5.90.21(react@18.3.1) + version: 5.90.21(react@19.2.6) ai: specifier: ^6.0.176 version: 6.0.177(zod@4.4.3) @@ -2186,7 +2189,7 @@ importers: version: 6.9.1 '@testing-library/react': specifier: ^16.3.2 - version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@testing-library/user-event': specifier: ^14.5.2 version: 14.6.1(@testing-library/dom@10.4.1) @@ -2194,11 +2197,11 @@ importers: specifier: 22.19.15 version: 22.19.15 '@types/react': - specifier: ^18.3.12 - version: 18.3.31 + specifier: ^19.2.14 + version: 19.2.14 '@types/react-dom': - specifier: ^18.3.1 - version: 18.3.7(@types/react@18.3.31) + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.14) '@vitejs/plugin-react': specifier: ^4.3.4 version: 4.7.0(vite@6.4.3(@types/node@22.19.15)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.22.4)(yaml@2.9.0)) @@ -2230,11 +2233,11 @@ importers: specifier: ^2.12.11 version: 2.14.6(@types/node@22.19.15)(typescript@6.0.3) react: - specifier: ^18.3.1 - version: 18.3.1 + specifier: ^19.2.5 + version: 19.2.6 react-dom: - specifier: ^18.3.1 - version: 18.3.1(react@18.3.1) + specifier: ^19.2.5 + version: 19.2.6(react@19.2.6) tsup: specifier: ^8.5.1 version: 8.5.1(patch_hash=78092cc873f6c3c61ff4846b4b326faa7db54bb3c1506c510614d708601e2b7f)(@microsoft/api-extractor@7.58.9(@types/node@22.19.15))(@swc/core@1.15.7(@swc/helpers@0.5.17))(jiti@2.7.0)(postcss@8.5.15)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0) @@ -18230,11 +18233,6 @@ packages: '@types/range-parser@1.2.7': resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} - '@types/react-dom@18.3.7': - resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} - peerDependencies: - '@types/react': ^18.0.0 - '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -18252,9 +18250,6 @@ packages: '@types/react-syntax-highlighter@15.5.13': resolution: {integrity: sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA==} - '@types/react@18.3.31': - resolution: {integrity: sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==} - '@types/react@19.2.14': resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} @@ -29215,10 +29210,10 @@ snapshots: '@ai-sdk/provider-utils': 2.2.8(zod@4.4.3) zod: 4.4.3 - '@ai-sdk/google@2.0.72(@opentelemetry/api@1.9.1)(@types/node@22.19.15)(@vitest/coverage-v8@4.1.8)(@vitest/ui@4.1.8)(jsdom@27.4.0(@noble/hashes@2.2.0)(bufferutil@4.1.0))(typescript@6.0.3)(vite@7.3.5(@types/node@22.19.15)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.22.4)(yaml@2.9.0))(zod@3.25.76)': + '@ai-sdk/google@2.0.72(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/coverage-v8@4.1.8)(@vitest/ui@4.1.8)(jsdom@27.4.0(@noble/hashes@2.2.0)(bufferutil@4.1.0))(typescript@6.0.3)(vite@7.3.5(@types/node@22.19.15)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.22.4)(yaml@2.9.0))(zod@3.25.76)': dependencies: '@ai-sdk/provider': 2.0.3 - '@ai-sdk/provider-utils': 3.0.25(@opentelemetry/api@1.9.1)(@types/node@22.19.15)(@vitest/coverage-v8@4.1.8)(@vitest/ui@4.1.8)(jsdom@27.4.0(@noble/hashes@2.2.0)(bufferutil@4.1.0))(typescript@6.0.3)(vite@7.3.5(@types/node@22.19.15)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.22.4)(yaml@2.9.0))(zod@3.25.76) + '@ai-sdk/provider-utils': 3.0.25(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/coverage-v8@4.1.8)(@vitest/ui@4.1.8)(jsdom@27.4.0(@noble/hashes@2.2.0)(bufferutil@4.1.0))(typescript@6.0.3)(vite@7.3.5(@types/node@22.19.15)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.22.4)(yaml@2.9.0))(zod@3.25.76) zod: 3.25.76 transitivePeerDependencies: - '@edge-runtime/vm' @@ -29375,6 +29370,26 @@ snapshots: '@ai-sdk/provider-utils': 2.2.8(zod@4.4.3) zod: 4.4.3 + '@ai-sdk/openai@2.0.106(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/coverage-v8@4.1.8)(@vitest/ui@4.1.8)(jsdom@27.4.0(@noble/hashes@2.2.0)(bufferutil@4.1.0))(typescript@6.0.3)(vite@7.3.5(@types/node@22.19.15)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.22.4)(yaml@2.9.0))(zod@3.25.76)': + dependencies: + '@ai-sdk/provider': 2.0.3 + '@ai-sdk/provider-utils': 3.0.25(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/coverage-v8@4.1.8)(@vitest/ui@4.1.8)(jsdom@27.4.0(@noble/hashes@2.2.0)(bufferutil@4.1.0))(typescript@6.0.3)(vite@7.3.5(@types/node@22.19.15)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.22.4)(yaml@2.9.0))(zod@3.25.76) + zod: 3.25.76 + transitivePeerDependencies: + - '@edge-runtime/vm' + - '@opentelemetry/api' + - '@types/node' + - '@vitest/browser-playwright' + - '@vitest/browser-preview' + - '@vitest/browser-webdriverio' + - '@vitest/coverage-istanbul' + - '@vitest/coverage-v8' + - '@vitest/ui' + - happy-dom + - jsdom + - typescript + - vite + '@ai-sdk/openai@2.0.106(@opentelemetry/api@1.9.1)(@types/node@22.19.15)(@vitest/coverage-v8@4.1.8)(@vitest/ui@4.1.8)(jsdom@27.4.0(@noble/hashes@2.2.0)(bufferutil@4.1.0))(typescript@6.0.3)(vite@7.3.5(@types/node@22.19.15)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.22.4)(yaml@2.9.0))(zod@3.25.76)': dependencies: '@ai-sdk/provider': 2.0.3 @@ -39847,11 +39862,6 @@ snapshots: '@tanstack/query-core@5.90.20': {} - '@tanstack/react-query@5.90.21(react@18.3.1)': - dependencies: - '@tanstack/query-core': 5.90.20 - react: 18.3.1 - '@tanstack/react-query@5.90.21(react@19.2.6)': dependencies: '@tanstack/query-core': 5.90.20 @@ -39990,16 +40000,6 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 - '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@babel/runtime': 7.29.7 - '@testing-library/dom': 10.4.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.31 - '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@babel/runtime': 7.29.7 @@ -40524,10 +40524,6 @@ snapshots: '@types/range-parser@1.2.7': {} - '@types/react-dom@18.3.7(@types/react@18.3.31)': - dependencies: - '@types/react': 18.3.31 - '@types/react-dom@19.2.3(@types/react@19.2.14)': dependencies: '@types/react': 19.2.14 @@ -40553,11 +40549,6 @@ snapshots: dependencies: '@types/react': 19.2.14 - '@types/react@18.3.31': - dependencies: - '@types/prop-types': 15.7.15 - csstype: 3.2.3 - '@types/react@19.2.14': dependencies: csstype: 3.2.3 @@ -41181,7 +41172,7 @@ snapshots: obug: 2.1.1 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@22.19.15)(@vitest/coverage-v8@4.1.8)(@vitest/ui@4.1.8)(jsdom@26.1.0(bufferutil@4.1.0))(msw@2.14.6(@types/node@22.19.15)(typescript@6.0.3))(vite@6.4.3(@types/node@22.19.15)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.22.4)(yaml@2.9.0)) + vitest: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@22.19.15)(@vitest/coverage-v8@4.1.8)(@vitest/ui@4.1.8)(jsdom@27.4.0(@noble/hashes@2.2.0)(bufferutil@4.1.0))(msw@2.14.6(@types/node@22.19.15)(typescript@6.0.3))(vite@7.3.5(@types/node@22.19.15)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.22.4)(yaml@2.9.0)) '@vitest/eslint-plugin@1.6.19(@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)(vitest@4.1.8)': dependencies: @@ -41274,7 +41265,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vitest: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@22.19.15)(@vitest/coverage-v8@4.1.8)(@vitest/ui@4.1.8)(jsdom@26.1.0(bufferutil@4.1.0))(msw@2.14.6(@types/node@22.19.15)(typescript@6.0.3))(vite@6.4.3(@types/node@22.19.15)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.22.4)(yaml@2.9.0)) + vitest: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@22.19.15)(@vitest/coverage-v8@4.1.8)(@vitest/ui@4.1.8)(jsdom@27.4.0(@noble/hashes@2.2.0)(bufferutil@4.1.0))(msw@2.14.6(@types/node@22.19.15)(typescript@6.0.3))(vite@7.3.5(@types/node@22.19.15)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.22.4)(yaml@2.9.0)) '@vitest/utils@3.2.4': dependencies: From edfb07cec46f16c2addb1129d09fe99e6b25525b Mon Sep 17 00:00:00 2001 From: mfrachet Date: Mon, 29 Jun 2026 15:20:17 +0200 Subject: [PATCH 2/5] Render MastraCode web chat through standard Mastra message parts Route the web transcript through @mastra/react MessageFactory using a controller-message accumulator that converts AgentControllerMessage payloads into MastraDBMessage parts. This replaces the bespoke segment/tool render path so message rendering uses the shared Mastra parts model. Visible chat behavior (role labels, bubbles, markdown, thinking, streaming cursor, inline tool cards, expand/collapse) is preserved, and harness/status metadata renders as status cards. Updates the colocated accumulator, transcript reducer, and MSW/e2e coverage to the new representation. Co-Authored-By: Mastra Code (anthropic/claude-opus-4-8) --- .../e2e/web/fixtures/plan-approval.json | 3 +-- .../e2e/web/notification.scenario.test.ts | 4 ++-- .../e2e/web/sse-reconnect.scenario.test.ts | 14 +++++++---- .../web/transcript-hydrate.scenario.test.ts | 7 +++++- mastracode/src/web/ui/App.tsx | 8 ++++++- .../__tests__/message-rendering.msw.test.tsx | 11 +++++++-- .../agent-controller-message-accumulator.ts | 2 +- mastracode/src/web/ui/components.tsx | 8 +++---- mastracode/src/web/ui/transcript.test.ts | 24 +++++++++++++------ mastracode/src/web/ui/transcript.ts | 7 ++++-- packages/core/src/agent-controller/session.ts | 3 +-- 11 files changed, 63 insertions(+), 28 deletions(-) diff --git a/mastracode/e2e/web/fixtures/plan-approval.json b/mastracode/e2e/web/fixtures/plan-approval.json index 0ef3343a2c4..a4e5238d402 100644 --- a/mastracode/e2e/web/fixtures/plan-approval.json +++ b/mastracode/e2e/web/fixtures/plan-approval.json @@ -13,8 +13,7 @@ "id": "call_submit_plan", "name": "submit_plan", "arguments": { - "title": "Add a README", - "plan": "1. Create README.md\n2. Describe the project" + "path": ".mastracode/plans/add-readme.md" } } ] diff --git a/mastracode/e2e/web/notification.scenario.test.ts b/mastracode/e2e/web/notification.scenario.test.ts index af62f1f4b82..73c003808c6 100644 --- a/mastracode/e2e/web/notification.scenario.test.ts +++ b/mastracode/e2e/web/notification.scenario.test.ts @@ -38,7 +38,7 @@ describe('web scenario: notification', () => { // Verify transcript has both the notification-driven response and // the transcript state reflects the notification was processed. const state = driver.state(); - const assistantEntries = state.entries.filter(e => e.kind === 'assistant'); + const assistantEntries = state.entries.filter(e => e.kind === 'message' && e.message.role === 'assistant'); expect(assistantEntries.length).toBeGreaterThan(0); }, }); @@ -62,7 +62,7 @@ describe('web scenario: notification', () => { await driver.waitForText('received the notification', 20_000); const state = driver.state(); - expect(state.entries.some(e => e.kind === 'assistant')).toBe(true); + expect(state.entries.some(e => e.kind === 'message' && e.message.role === 'assistant')).toBe(true); }, }); }); diff --git a/mastracode/e2e/web/sse-reconnect.scenario.test.ts b/mastracode/e2e/web/sse-reconnect.scenario.test.ts index 862c523d366..5174c4e1947 100644 --- a/mastracode/e2e/web/sse-reconnect.scenario.test.ts +++ b/mastracode/e2e/web/sse-reconnect.scenario.test.ts @@ -46,13 +46,19 @@ describe('web scenario: sse-reconnect', () => { // Send first message and wait for response await session.sendMessage('before disconnect'); - // Flatten transcript entries to text (assistant entries hold ordered - // segments rather than a single text field). + // Flatten transcript entries to text. Message entries hold ordered + // content parts; extract text/reasoning parts in order. const flatten = () => transcript.entries .map(e => { - if (e.kind === 'assistant') { - return e.segments.map(s => (s.kind === 'text' || s.kind === 'thinking' ? s.text : '')).join(''); + if (e.kind === 'message') { + return e.message.content.parts + .map(part => { + if (part.type === 'text') return part.text; + if (part.type === 'reasoning') return part.reasoning; + return ''; + }) + .join(''); } if (e.kind === 'notice') return e.text; return ''; diff --git a/mastracode/e2e/web/transcript-hydrate.scenario.test.ts b/mastracode/e2e/web/transcript-hydrate.scenario.test.ts index 0027849d63d..3439c254ba3 100644 --- a/mastracode/e2e/web/transcript-hydrate.scenario.test.ts +++ b/mastracode/e2e/web/transcript-hydrate.scenario.test.ts @@ -53,7 +53,12 @@ describe('transcript hydrate (thread history rendering)', () => { expect(state.entries).toHaveLength(2); expect(state.entries[0]).toMatchObject({ kind: 'message', id: 'u1', message: { role: 'user' } }); expect(messageText(state.entries[0])).toBe('hello there'); - expect(state.entries[1]).toMatchObject({ kind: 'message', id: 'a1', message: { role: 'assistant' }, streaming: false }); + expect(state.entries[1]).toMatchObject({ + kind: 'message', + id: 'a1', + message: { role: 'assistant' }, + streaming: false, + }); expect(messageText(state.entries[1])).toBe('hi, how can I help?'); }); diff --git a/mastracode/src/web/ui/App.tsx b/mastracode/src/web/ui/App.tsx index 9c8164f3d55..fef091fb00b 100644 --- a/mastracode/src/web/ui/App.tsx +++ b/mastracode/src/web/ui/App.tsx @@ -225,7 +225,13 @@ export default function App() { lastEntry.message.role === 'assistant' && lastEntry.message.content.parts.some(part => part.type === 'text' && part.text.trim().length > 0); const showWorkingIndicator = - busy && !(lastEntry?.kind === 'message' && lastEntry.message.role === 'assistant' && lastEntry.streaming && lastEntryHasText); + busy && + !( + lastEntry?.kind === 'message' && + lastEntry.message.role === 'assistant' && + lastEntry.streaming && + lastEntryHasText + ); // A restored active project from a pre-resourceId build won't have one yet; // backfill it so the session can connect. Runs once per project that needs it. diff --git a/mastracode/src/web/ui/__tests__/message-rendering.msw.test.tsx b/mastracode/src/web/ui/__tests__/message-rendering.msw.test.tsx index 9fbdc2cd2f0..37f1405d1cf 100644 --- a/mastracode/src/web/ui/__tests__/message-rendering.msw.test.tsx +++ b/mastracode/src/web/ui/__tests__/message-rendering.msw.test.tsx @@ -73,7 +73,9 @@ function useAgentControllerHandlers({ events?: AgentControllerEvent[]; } = {}) { server.use( - http.post(`${API}/sessions`, () => HttpResponse.json({ controllerId: 'code', resourceId: RESOURCE_ID, threadId: THREAD_ID })), + http.post(`${API}/sessions`, () => + HttpResponse.json({ controllerId: 'code', resourceId: RESOURCE_ID, threadId: THREAD_ID }), + ), http.get(`${API}/modes`, () => HttpResponse.json({ modes: [{ id: 'build', label: 'Build' }] })), http.get(`${API}/models`, () => HttpResponse.json({ models: [] })), http.get(SESSION, () => HttpResponse.json(sessionState())), @@ -138,7 +140,12 @@ describe('MastraCode message rendering', () => { useAgentControllerHandlers({ events: [ { type: 'tool_input_start', toolCallId: 'tool-live', toolName: 'execute_command' }, - { type: 'tool_input_delta', toolCallId: 'tool-live', argsTextDelta: '{"command":"pnpm test"}', toolName: 'execute_command' }, + { + type: 'tool_input_delta', + toolCallId: 'tool-live', + argsTextDelta: '{"command":"pnpm test"}', + toolName: 'execute_command', + }, { type: 'tool_start', toolCallId: 'tool-live', toolName: 'execute_command', args: { command: 'pnpm test' } }, { type: 'shell_output', toolCallId: 'tool-live', output: 'passing tests', stream: 'stdout' }, { type: 'tool_end', toolCallId: 'tool-live', result: 'ok' }, diff --git a/mastracode/src/web/ui/agent-controller-message-accumulator.ts b/mastracode/src/web/ui/agent-controller-message-accumulator.ts index ea565db0fb8..42404bf6097 100644 --- a/mastracode/src/web/ui/agent-controller-message-accumulator.ts +++ b/mastracode/src/web/ui/agent-controller-message-accumulator.ts @@ -127,7 +127,7 @@ function isErrorEvent(event: AgentControllerEvent): event is Extract): MastraDBMessage { - const message = typeof event.error === 'string' ? event.error : event.error.message ?? 'Controller error'; + const message = typeof event.error === 'string' ? event.error : (event.error.message ?? 'Controller error'); const id = `error-${Date.now()}-${Math.random().toString(36).slice(2)}`; const harnessContent: AgentControllerMessageContent[] = [ { type: 'harness-error', text: message, ...(event.errorType ? { errorType: event.errorType } : {}) }, diff --git a/mastracode/src/web/ui/components.tsx b/mastracode/src/web/ui/components.tsx index d15d6f3847d..41925091b60 100644 --- a/mastracode/src/web/ui/components.tsx +++ b/mastracode/src/web/ui/components.tsx @@ -533,10 +533,6 @@ function MessageBubble({ entry }: { entry: MessageEntry }) { part.type === 'file', ); - const status = statusMetadata(entry); - if (status) return ; - if (entry.message.role === 'assistant' && !hasRenderablePart) return null; - const lastTextPart = (() => { for (let i = parts.length - 1; i >= 0; i--) { if (parts[i].type === 'text') return parts[i]; @@ -621,6 +617,10 @@ function MessageBubble({ entry }: { entry: MessageEntry }) { [allExpanded, entry.message.role, entry.runtimeTools, entry.streaming, lastTextPart], ); + const status = statusMetadata(entry); + if (status) return ; + if (entry.message.role === 'assistant' && !hasRenderablePart) return null; + return null} />; } diff --git a/mastracode/src/web/ui/transcript.test.ts b/mastracode/src/web/ui/transcript.test.ts index a0f5b67f11f..18f11daf097 100644 --- a/mastracode/src/web/ui/transcript.test.ts +++ b/mastracode/src/web/ui/transcript.test.ts @@ -13,7 +13,9 @@ function messageParts(entry: unknown): unknown[] { } function isMessageEntry(entry: unknown): entry is MessageEntryFixture { - return typeof entry === 'object' && entry !== null && 'kind' in entry && entry.kind === 'message' && 'message' in entry; + return ( + typeof entry === 'object' && entry !== null && 'kind' in entry && entry.kind === 'message' && 'message' in entry + ); } describe('transcript reducer message entries', () => { @@ -43,7 +45,11 @@ describe('transcript reducer message entries', () => { expect(state.entries[1]).toMatchObject({ kind: 'message', id: 'assistant-1', streaming: false }); expect(messageParts(state.entries[1])).toEqual([ { type: 'text', text: 'I will inspect it.' }, - { type: 'reasoning', reasoning: 'Need the file first.', details: [{ type: 'text', text: 'Need the file first.' }] }, + { + type: 'reasoning', + reasoning: 'Need the file first.', + details: [{ type: 'text', text: 'Need the file first.' }], + }, { type: 'tool-invocation', toolInvocation: { @@ -58,7 +64,11 @@ describe('transcript reducer message entries', () => { }); it('streams message updates without replacing non-message transcript state', () => { - const withNotice = transcriptReducer(initialTranscript, { type: 'localNotice', level: 'info', text: 'Command handled' }); + const withNotice = transcriptReducer(initialTranscript, { + type: 'localNotice', + level: 'info', + text: 'Command handled', + }); const state = transcriptReducer(withNotice, { type: 'event', @@ -71,9 +81,7 @@ describe('transcript reducer message entries', () => { expect(state.pending).toBe(false); expect(state.entries[0]).toMatchObject({ kind: 'notice', text: 'Command handled' }); expect(state.entries[1]).toMatchObject({ kind: 'message', id: 'assistant-1', streaming: true }); - expect(messageParts(state.entries[1])).toEqual([ - { type: 'text', text: 'Streaming text' }, - ]); + expect(messageParts(state.entries[1])).toEqual([{ type: 'text', text: 'Streaming text' }]); }); it('keeps tool lifecycle events visible inline before a message update re-emits the tool call', () => { @@ -118,7 +126,9 @@ describe('transcript reducer message entries', () => { type: 'event', event: { type: 'task_updated', - tasks: [{ id: 'task-1', content: 'Refactor transcript', status: 'in_progress', activeForm: 'Refactoring transcript' }], + tasks: [ + { id: 'task-1', content: 'Refactor transcript', status: 'in_progress', activeForm: 'Refactoring transcript' }, + ], }, }); const state = transcriptReducer(withTask, { diff --git a/mastracode/src/web/ui/transcript.ts b/mastracode/src/web/ui/transcript.ts index 5364abae2a4..702a4c8fad4 100644 --- a/mastracode/src/web/ui/transcript.ts +++ b/mastracode/src/web/ui/transcript.ts @@ -581,7 +581,9 @@ function hasAssistantText(state: TranscriptState): boolean { if (idx === -1) return false; const entry = state.entries[idx]; if (entry.kind !== 'message') return false; - return entry.message.content.parts.some(part => part.type === 'text' && 'text' in part && part.text.trim().length > 0); + return entry.message.content.parts.some( + part => part.type === 'text' && 'text' in part && part.text.trim().length > 0, + ); } /** Find the latest assistant entry, creating one if none exists. */ @@ -616,7 +618,8 @@ function withTool( const parts = [...entry.message.content.parts]; const runtimeTools = { ...(entry.runtimeTools ?? {}) }; - const existing = runtimeTools[toolCallId] ?? toolCallFromPart(parts.find(part => toolCallIdForPart(part) === toolCallId)); + const existing = + runtimeTools[toolCallId] ?? toolCallFromPart(parts.find(part => toolCallIdForPart(part) === toolCallId)); const tool = update( existing ?? { toolCallId, diff --git a/packages/core/src/agent-controller/session.ts b/packages/core/src/agent-controller/session.ts index 3baff1724f8..9dd1b6e7dc1 100644 --- a/packages/core/src/agent-controller/session.ts +++ b/packages/core/src/agent-controller/session.ts @@ -3490,8 +3490,7 @@ export class Session { // The resume data is the user's answer (a bare string), which the approval // re-check would reject because it cannot carry an `{ approved }` field. // Exempt these tools so the answer reaches the model as-is. - const isInteractive = - suspension.toolName === 'ask_user' || suspension.toolName === 'request_access'; + const isInteractive = suspension.toolName === 'ask_user' || suspension.toolName === 'request_access'; if (isInteractive) { sharedOptions.requireToolApproval = false; } From 360a539ef1b017e6c6cb7cad79730e2eae92de7b Mon Sep 17 00:00:00 2001 From: mfrachet Date: Mon, 29 Jun 2026 16:55:43 +0200 Subject: [PATCH 3/5] wip --- ...ent-controller-message-accumulator.test.ts | 57 +------------------ .../agent-controller-message-accumulator.ts | 50 +--------------- 2 files changed, 3 insertions(+), 104 deletions(-) diff --git a/mastracode/src/web/ui/agent-controller-message-accumulator.test.ts b/mastracode/src/web/ui/agent-controller-message-accumulator.test.ts index 84a9ee7cc7c..88f6ba02e14 100644 --- a/mastracode/src/web/ui/agent-controller-message-accumulator.test.ts +++ b/mastracode/src/web/ui/agent-controller-message-accumulator.test.ts @@ -1,8 +1,7 @@ -import type { AgentControllerEvent, AgentControllerMessage } from '@mastra/client-js'; -import type { MastraDBMessage } from '@mastra/core/agent'; +import type { AgentControllerMessage } from '@mastra/client-js'; import { describe, expect, it } from 'vitest'; -import { accumulateMessages, toMastraDBMessage } from './agent-controller-message-accumulator'; +import { toMastraDBMessage } from './agent-controller-message-accumulator'; describe('agent controller message accumulator', () => { it('converts visible controller content into ordered Mastra message parts', () => { @@ -63,56 +62,4 @@ describe('agent controller message accumulator', () => { { type: 'text', text: 'Thread title updated: Refactor transcript renderer' }, ]); }); - - it('upserts repeated message events by message id', () => { - const existingMessage = toMastraDBMessage({ - id: 'message-1', - role: 'assistant', - content: [{ type: 'text', text: 'Initial text' }], - }); - const event: AgentControllerEvent = { - type: 'message_update', - message: { - id: 'message-1', - role: 'assistant', - content: [{ type: 'text', text: 'Updated text' }], - }, - }; - - const accumulated = accumulateMessages([existingMessage], event); - - expect(accumulated).toHaveLength(1); - expect(accumulated[0]?.content.parts).toEqual([{ type: 'text', text: 'Updated text' }]); - expect(accumulated[0]?.createdAt).toBe(existingMessage.createdAt); - }); - - it('appends controller errors as synthetic system messages', () => { - const existingMessages: MastraDBMessage[] = []; - const event: AgentControllerEvent = { - type: 'error', - error: { message: 'The controller failed' }, - errorType: 'controller', - }; - - const accumulated = accumulateMessages(existingMessages, event); - - expect(accumulated).toHaveLength(1); - expect(accumulated[0]).toMatchObject({ role: 'system' }); - expect(accumulated[0]?.id).toMatch(/^error-/); - expect(accumulated[0]?.content.parts).toEqual([{ type: 'text', text: 'The controller failed' }]); - expect(accumulated[0]?.content.metadata?.harnessContent).toEqual([ - { type: 'harness-error', text: 'The controller failed', errorType: 'controller' }, - ]); - }); - - it('returns existing messages unchanged for unrelated events', () => { - const existingMessages: MastraDBMessage[] = [ - toMastraDBMessage({ id: 'message-1', role: 'assistant', content: [{ type: 'text', text: 'Keep me' }] }), - ]; - const event: AgentControllerEvent = { type: 'agent_start' }; - - const accumulated = accumulateMessages(existingMessages, event); - - expect(accumulated).toBe(existingMessages); - }); }); diff --git a/mastracode/src/web/ui/agent-controller-message-accumulator.ts b/mastracode/src/web/ui/agent-controller-message-accumulator.ts index 42404bf6097..d8de36eef58 100644 --- a/mastracode/src/web/ui/agent-controller-message-accumulator.ts +++ b/mastracode/src/web/ui/agent-controller-message-accumulator.ts @@ -1,27 +1,8 @@ -import type { AgentControllerEvent, AgentControllerMessage, AgentControllerMessageContent } from '@mastra/client-js'; +import type { AgentControllerMessage, AgentControllerMessageContent } from '@mastra/client-js'; import type { MastraDBMessage, MastraMessagePart } from '@mastra/core/agent'; const fallbackCreatedAtByMessageId = new Map(); -export function accumulateMessages(messages: MastraDBMessage[], event: AgentControllerEvent): MastraDBMessage[] { - if (isMessageEvent(event)) { - const nextMessage = toMastraDBMessage(event.message); - const existingIndex = messages.findIndex(message => message.id === nextMessage.id); - - if (existingIndex === -1) { - return [...messages, nextMessage]; - } - - return messages.map((message, index) => (index === existingIndex ? nextMessage : message)); - } - - if (isErrorEvent(event)) { - return [...messages, toHarnessErrorMessage(event)]; - } - - return messages; -} - export function toMastraDBMessage(message: AgentControllerMessage): MastraDBMessage { const harnessContent = message.content.filter(isHarnessMetadataContent); @@ -116,35 +97,6 @@ function toStatusText(part: AgentControllerMessageContent): string | null { return part.text ?? null; } -function isMessageEvent( - event: AgentControllerEvent, -): event is Extract { - return event.type === 'message_start' || event.type === 'message_update' || event.type === 'message_end'; -} - -function isErrorEvent(event: AgentControllerEvent): event is Extract { - return event.type === 'error' && 'error' in event; -} - -function toHarnessErrorMessage(event: Extract): MastraDBMessage { - const message = typeof event.error === 'string' ? event.error : (event.error.message ?? 'Controller error'); - const id = `error-${Date.now()}-${Math.random().toString(36).slice(2)}`; - const harnessContent: AgentControllerMessageContent[] = [ - { type: 'harness-error', text: message, ...(event.errorType ? { errorType: event.errorType } : {}) }, - ]; - - return { - id, - role: 'system', - createdAt: createdAtForMessage(id), - content: { - format: 2, - parts: [{ type: 'text', text: message }], - metadata: { harnessContent }, - }, - }; -} - function createdAtForMessage(messageId: string): Date { const existing = fallbackCreatedAtByMessageId.get(messageId); if (existing) return existing; From aac692b9913eb51ae1b6fb9adedfc47f429d44e7 Mon Sep 17 00:00:00 2001 From: mfrachet Date: Mon, 29 Jun 2026 19:16:24 +0200 Subject: [PATCH 4/5] wip --- .changeset/thirty-candies-invite.md | 2 +- .../e2e/web/streaming-text.scenario.test.ts | 4 ++- .../web/transcript-hydrate.scenario.test.ts | 6 ++-- .../__tests__/message-rendering.msw.test.tsx | 9 +++-- .../agent-controller-message-accumulator.ts | 35 +++++++++++++------ mastracode/src/web/ui/components.tsx | 17 +++++++-- mastracode/src/web/ui/transcript.ts | 9 ++++- 7 files changed, 60 insertions(+), 22 deletions(-) diff --git a/.changeset/thirty-candies-invite.md b/.changeset/thirty-candies-invite.md index ae519f11303..37381de6b81 100644 --- a/.changeset/thirty-candies-invite.md +++ b/.changeset/thirty-candies-invite.md @@ -2,4 +2,4 @@ 'mastracode': patch --- -Improved MastraCode web chat message rendering to use standard Mastra message parts. +Improved MastraCode web chat so hydrated and streaming messages render consistently, including tool cards, reasoning, and failed-tool states. diff --git a/mastracode/e2e/web/streaming-text.scenario.test.ts b/mastracode/e2e/web/streaming-text.scenario.test.ts index 36486cf9889..b9eda561f94 100644 --- a/mastracode/e2e/web/streaming-text.scenario.test.ts +++ b/mastracode/e2e/web/streaming-text.scenario.test.ts @@ -20,7 +20,9 @@ const scenario: WebScenario = { const assistantEntries = state.entries.filter(e => e.kind === 'message' && e.message.role === 'assistant'); const last = assistantEntries[assistantEntries.length - 1]; if (!last || last.kind !== 'message') throw new Error('No assistant entry found'); - if (last.streaming) throw new Error('Expected streaming=false after message_end, got true'); + if (last.streaming !== false) { + throw new Error(`Expected streaming=false after message_end, got ${String(last.streaming)}`); + } const assistantText = last.message.content.parts .filter(part => part.type === 'text') .map(part => (part.type === 'text' ? part.text : '')) diff --git a/mastracode/e2e/web/transcript-hydrate.scenario.test.ts b/mastracode/e2e/web/transcript-hydrate.scenario.test.ts index 3439c254ba3..bdc6d27e195 100644 --- a/mastracode/e2e/web/transcript-hydrate.scenario.test.ts +++ b/mastracode/e2e/web/transcript-hydrate.scenario.test.ts @@ -160,11 +160,9 @@ describe('transcript hydrate (thread history rendering)', () => { const assistant = state.entries[0]; if (assistant.kind !== 'message') throw new Error('expected assistant entry'); const [tool] = toolParts(assistant); - expect(tool?.toolInvocation.state).toBe('result'); + expect(tool?.toolInvocation.state).toBe('output-error'); expect(tool?.toolInvocation.result).toBe('command not found'); - expect(assistant.message.content.metadata?.harnessContent).toContainEqual( - expect.objectContaining({ type: 'tool_result', id: 'tc-9', isError: true }), - ); + expect(tool?.toolInvocation.errorText).toBe('command not found'); }); it('produces an empty transcript for a thread with no history', () => { diff --git a/mastracode/src/web/ui/__tests__/message-rendering.msw.test.tsx b/mastracode/src/web/ui/__tests__/message-rendering.msw.test.tsx index 37f1405d1cf..f2984f10a52 100644 --- a/mastracode/src/web/ui/__tests__/message-rendering.msw.test.tsx +++ b/mastracode/src/web/ui/__tests__/message-rendering.msw.test.tsx @@ -53,16 +53,21 @@ function sse(events: AgentControllerEvent[] = []): Response { function delayedSse(event: AgentControllerEvent) { const encoder = new TextEncoder(); let emit: () => void = () => {}; + let markReady: () => void = () => {}; + const ready = new Promise(resolve => { + markReady = resolve; + }); const response = new Response( new ReadableStream({ start(controller) { emit = () => controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`)); + markReady(); }, cancel() {}, }), { headers: { 'content-type': 'text/event-stream' } }, ); - return { response, emit }; + return { response, emit: () => ready.then(() => emit()) }; } function useAgentControllerHandlers({ @@ -130,7 +135,7 @@ describe('MastraCode message rendering', () => { renderWithProviders(); expect(await screen.findByText('ready')).toBeInTheDocument(); - stream.emit(); + await stream.emit(); expect(await screen.findByText('Streaming now')).toBeInTheDocument(); }); diff --git a/mastracode/src/web/ui/agent-controller-message-accumulator.ts b/mastracode/src/web/ui/agent-controller-message-accumulator.ts index d8de36eef58..b8337813b70 100644 --- a/mastracode/src/web/ui/agent-controller-message-accumulator.ts +++ b/mastracode/src/web/ui/agent-controller-message-accumulator.ts @@ -55,16 +55,29 @@ function toMastraMessageParts(content: AgentControllerMessageContent[]): MastraM const existingIndex = toolPartIndexById.get(toolCallId); const previousPart = existingIndex === undefined ? undefined : parts[existingIndex]; const previousInvocation = previousPart?.type === 'tool-invocation' ? previousPart.toolInvocation : undefined; - const resultPart: MastraMessagePart = { - type: 'tool-invocation', - toolInvocation: { - state: 'result', - toolCallId, - toolName: part.name ?? previousInvocation?.toolName ?? '', - args: previousInvocation?.args, - result: part.result, - }, - }; + const toolName = part.name ?? previousInvocation?.toolName ?? ''; + const resultPart: MastraMessagePart = part.isError + ? { + type: 'tool-invocation', + toolInvocation: { + state: 'output-error', + toolCallId, + toolName, + args: previousInvocation?.args, + result: part.result, + errorText: typeof part.result === 'string' ? part.result : JSON.stringify(part.result), + }, + } + : { + type: 'tool-invocation', + toolInvocation: { + state: 'result', + toolCallId, + toolName, + args: previousInvocation?.args, + result: part.result, + }, + }; if (existingIndex === undefined) { toolPartIndexById.set(toolCallId, parts.length); @@ -86,7 +99,7 @@ function toMastraMessageParts(content: AgentControllerMessageContent[]): MastraM } function isHarnessMetadataContent(part: AgentControllerMessageContent): boolean { - return !['text', 'thinking', 'tool_call'].includes(part.type); + return !['text', 'thinking', 'tool_call', 'tool_result'].includes(part.type); } function toStatusText(part: AgentControllerMessageContent): string | null { diff --git a/mastracode/src/web/ui/components.tsx b/mastracode/src/web/ui/components.tsx index 41925091b60..58678028108 100644 --- a/mastracode/src/web/ui/components.tsx +++ b/mastracode/src/web/ui/components.tsx @@ -301,8 +301,19 @@ function suspensionPayloadShape(payload: unknown): SuspendPayloadShape { } : undefined; + const optionsValue = + payload && typeof payload === 'object' && hasProperty(payload, 'options') ? payload.options : undefined; + const options = Array.isArray(optionsValue) + ? optionsValue.flatMap(option => { + const label = stringProperty(option, 'label'); + if (!label) return []; + return [{ label, description: stringProperty(option, 'description') }]; + }) + : undefined; + return { question: stringProperty(payload, 'question'), + options, requestedPath: stringProperty(payload, 'requestedPath'), reason: stringProperty(payload, 'reason'), title: stringProperty(payload, 'title'), @@ -626,13 +637,15 @@ function MessageBubble({ entry }: { entry: MessageEntry }) { function toolFromInvocationPart(part: ToolInvocationPart, runtime?: ToolCall): ToolCall { const invocation = part.toolInvocation; + const failed = invocation.state === 'output-error' || invocation.state === 'output-denied'; + const persistedResult = 'result' in invocation ? invocation.result : undefined; return { toolCallId: invocation.toolCallId, toolName: invocation.toolName, argsText: runtime?.argsText ?? '', args: runtime?.args ?? ('args' in invocation ? invocation.args : undefined), - status: runtime?.status ?? (invocation.state === 'result' ? 'done' : 'running'), - result: runtime?.result ?? ('result' in invocation ? invocation.result : undefined), + status: runtime?.status ?? (failed ? 'error' : invocation.state === 'result' ? 'done' : 'running'), + result: runtime?.result ?? persistedResult ?? invocation.errorText, output: runtime?.output ?? '', }; } diff --git a/mastracode/src/web/ui/transcript.ts b/mastracode/src/web/ui/transcript.ts index 702a4c8fad4..b2b43a83952 100644 --- a/mastracode/src/web/ui/transcript.ts +++ b/mastracode/src/web/ui/transcript.ts @@ -547,7 +547,14 @@ function toMessageEntry( function upsertAssistant(state: TranscriptState, message: AgentControllerMessage, streaming: boolean): TranscriptState { if (message.role !== 'assistant') return state; const entries = [...state.entries]; - const idx = entries.findIndex(e => e.kind === 'message' && e.message.role === 'assistant' && e.id === message.id); + let idx = entries.findIndex(e => e.kind === 'message' && e.message.role === 'assistant' && e.id === message.id); + if (idx === -1) { + const latestIdx = latestAssistantIndex(entries); + const latest = latestIdx === -1 ? undefined : entries[latestIdx]; + if (latest?.kind === 'message' && latest.message.role === 'assistant' && latest.id.startsWith('assistant-tools-')) { + idx = latestIdx; + } + } const prev = idx !== -1 ? entries[idx] : undefined; const prevEntry = prev?.kind === 'message' ? prev : undefined; const nextMessage = preserveRuntimeToolParts(toMastraDBMessage(message), prevEntry?.message); From abd05e0540e65e3352033265d504f5a60899a6de Mon Sep 17 00:00:00 2001 From: mfrachet Date: Tue, 30 Jun 2026 08:58:52 +0200 Subject: [PATCH 5/5] wip --- mastracode/src/web/ui/components.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mastracode/src/web/ui/components.tsx b/mastracode/src/web/ui/components.tsx index 58678028108..8b2781cdc26 100644 --- a/mastracode/src/web/ui/components.tsx +++ b/mastracode/src/web/ui/components.tsx @@ -314,7 +314,7 @@ function suspensionPayloadShape(payload: unknown): SuspendPayloadShape { return { question: stringProperty(payload, 'question'), options, - requestedPath: stringProperty(payload, 'requestedPath'), + requestedPath: stringProperty(payload, 'requestedPath') ?? stringProperty(payload, 'path'), reason: stringProperty(payload, 'reason'), title: stringProperty(payload, 'title'), plan,