Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/thirty-candies-invite.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'mastracode': patch
---

Improved MastraCode web chat so hydrated and streaming messages render consistently, including tool cards, reasoning, and failed-tool states.
38 changes: 21 additions & 17 deletions mastracode/e2e/web/driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 '';
}
Expand Down
3 changes: 1 addition & 2 deletions mastracode/e2e/web/fixtures/plan-approval.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
]
Expand Down
4 changes: 2 additions & 2 deletions mastracode/e2e/web/notification.scenario.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
},
});
Expand All @@ -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);
},
});
});
Expand Down
14 changes: 10 additions & 4 deletions mastracode/e2e/web/sse-reconnect.scenario.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 '';
Expand Down
14 changes: 8 additions & 6 deletions mastracode/e2e/web/streaming-text.scenario.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,15 @@ 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.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 : ''))
if (!last || last.kind !== 'message') throw new Error('No assistant entry found');
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 : ''))
.join('');
if (!assistantText.includes('Streaming test response')) {
throw new Error(`Unexpected text: ${assistantText}`);
Expand Down
77 changes: 47 additions & 30 deletions mastracode/e2e/web/transcript-hydrate.scenario.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}

/**
Expand Down Expand Up @@ -41,15 +51,22 @@ 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)', () => {
Expand All @@ -69,7 +86,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');
});
Expand All @@ -91,17 +108,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',
});
});
Expand All @@ -122,15 +138,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', () => {
Expand All @@ -144,8 +158,11 @@ 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('output-error');
expect(tool?.toolInvocation.result).toBe('command not found');
expect(tool?.toolInvocation.errorText).toBe('command not found');
});

it('produces an empty transcript for a thread with no history', () => {
Expand Down
9 changes: 5 additions & 4 deletions mastracode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@
"@mastra/memory": "workspace:*",
"@mastra/observability": "workspace:*",
"@mastra/pg": "workspace:*",
"@mastra/react": "workspace:*",
"@mastra/schema-compat": "workspace:*",
"@mastra/server": "workspace:*",
"@mastra/stagehand": "workspace:*",
Expand Down Expand Up @@ -127,8 +128,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:",
Expand All @@ -139,8 +140,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:",
Expand Down
28 changes: 21 additions & 7 deletions mastracode/src/web/ui/App.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -36,6 +37,8 @@ import { useAgentControllerSession } from './useAgentControllerSession';

export default function App() {
const { toast } = useToast();
const { baseUrl } = useApiConfig();

// ── Projects (localStorage) ─────────────────────────────────────────
const [projects, setProjects] = useState<Project[]>(() => loadProjects());
// Restore the last active project on reload (if it still exists), so the
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -216,8 +221,17 @@ 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.
Expand Down
3 changes: 2 additions & 1 deletion mastracode/src/web/ui/SettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 },
Expand Down
Loading
Loading