feat: streaming shell output while commands run (#10808)

This commit is contained in:
Lifei Zhou
2026-07-30 21:06:35 +10:00
committed by GitHub
parent 2694fff7e3
commit cf312f1d1d
14 changed files with 1030 additions and 28 deletions
@@ -0,0 +1,103 @@
import { render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { IntlTestWrapper } from '../i18n/test-utils';
import type {
NotificationEvent,
ToolRequestMessageContent,
ToolResponseMessageContent,
} from '../types/message';
import ToolCallWithResponse from './ToolCallWithResponse';
const toolRequest: ToolRequestMessageContent = {
type: 'toolRequest',
id: 'tool-1',
toolCall: {
status: 'success',
value: {
name: 'developer__shell',
arguments: {
command: 'build',
},
},
},
};
const liveOutputNotification: NotificationEvent = {
type: 'Notification',
request_id: 'tool-1',
message: {
method: 'goose/live_output',
params: {
sequence: 1,
chunks: [
{
stream: 'stdout',
output: 'starting\n',
},
{
stream: 'stderr',
output: 'checking\n',
},
],
truncated: false,
},
},
};
const toolResponse: ToolResponseMessageContent = {
type: 'toolResponse',
id: 'tool-1',
toolResult: {
status: 'success',
value: {
content: [
{
type: 'text',
text: 'final result',
},
],
isError: false,
},
},
};
function renderToolCall(response?: ToolResponseMessageContent) {
return render(
<ToolCallWithResponse
isCancelledMessage={false}
toolRequest={toolRequest}
toolResponse={response}
notifications={[liveOutputNotification]}
isStreamingMessage={!response}
isPendingApproval={false}
/>,
{ wrapper: IntlTestWrapper }
);
}
describe('ToolCallWithResponse live output', () => {
beforeEach(() => {
vi.mocked(window.electron.getSetting).mockResolvedValue('detailed');
});
it('renders raw live output while running and replaces it with the final result', async () => {
const { rerender } = renderToolCall();
expect(screen.getByText(/starting/)).toHaveTextContent('starting checking');
expect(screen.queryByText(/stdout|stderr/)).not.toBeInTheDocument();
rerender(
<ToolCallWithResponse
isCancelledMessage={false}
toolRequest={toolRequest}
toolResponse={toolResponse}
notifications={[liveOutputNotification]}
isStreamingMessage={false}
isPendingApproval={false}
/>
);
expect(screen.queryByText(/starting/)).not.toBeInTheDocument();
expect(await screen.findByText('final result')).toBeInTheDocument();
});
});
@@ -9,6 +9,7 @@ import {
ToolRequestMessageContent,
ToolResponseMessageContent,
NotificationEvent,
LiveOutputNotificationParams,
ToolConfirmationData,
} from '../types/message';
import { cn, snakeToTitleCase } from '../utils';
@@ -451,6 +452,18 @@ const notificationToProgress = (notification: NotificationEvent): Progress => {
return message.params as Progress;
};
const liveOutputToString = (notifications: NotificationEvent[] | undefined): string =>
notifications
?.filter((notification) => {
const message = notification.message as { method?: string };
return message.method === 'goose/live_output';
})
.flatMap((notification) => {
const message = notification.message as { params?: LiveOutputNotificationParams };
return message.params?.chunks.map((chunk) => chunk.output) ?? [];
})
.join('') ?? '';
// Helper function to extract toolcall name
const getToolName = (toolCallName: string): string => {
const lastIndex = toolCallName.lastIndexOf('__');
@@ -534,6 +547,7 @@ function ToolCallView({
loadingStatus === 'success' && toolResponse?.toolResult
? getToolResultContent(toolResponse.toolResult)
: [];
const liveOutput = toolResponse ? '' : liveOutputToString(notifications);
const logs = notifications
?.filter((notification) => {
@@ -561,8 +575,9 @@ function ToolCallView({
(entries) => entries.sort((a, b) => b.progress - a.progress)[0]
);
const isRenderingProgress =
loadingStatus === 'loading' && (progressEntries.length > 0 || (logs || []).length > 0);
const isRenderingActivity =
loadingStatus === 'loading' &&
(progressEntries.length > 0 || (logs || []).length > 0 || liveOutput.length > 0);
// Function to create a descriptive representation of what the tool is doing
const getToolDescription = (): string | null => {
@@ -783,7 +798,7 @@ function ToolCallView({
);
return (
<ToolCallExpandable
isStartExpanded={isRenderingProgress || isExpandToolDetails}
isStartExpanded={isRenderingActivity || isExpandToolDetails}
isForceExpand={false}
label={
extensionTooltip ? (
@@ -833,6 +848,12 @@ function ToolCallView({
</div>
)}
{liveOutput && (
<div className="border-t border-border-primary">
<LiveOutputView output={liveOutput} />
</div>
)}
{toolResults.length === 0 &&
progressEntries.length > 0 &&
progressEntries.map((entry, index) => (
@@ -958,6 +979,30 @@ interface ToolResultViewProps {
isStartExpanded: boolean;
}
function LiveOutputView({ output }: { output: string }) {
const intl = useIntl();
const outputRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (outputRef.current) {
outputRef.current.scrollTop = outputRef.current.scrollHeight;
}
}, [output]);
return (
<ToolCallExpandable
label={<span className="pl-4 py-1 font-sans text-sm">{intl.formatMessage(i18n.output)}</span>}
isStartExpanded={true}
>
<div ref={outputRef} className="max-h-[20rem] overflow-y-auto px-4 py-3">
<pre className="font-mono text-xs text-textSubtle whitespace-pre-wrap break-words">
{output}
</pre>
</div>
</ToolCallExpandable>
);
}
function ToolResultView({ result, isStartExpanded }: ToolResultViewProps) {
const intl = useIntl();
const hasText = (c: ContentBlock): c is ContentBlock & { text: string } =>