feat: streaming shell output while commands run (#10808)
This commit is contained in:
@@ -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 } =>
|
||||
|
||||
Reference in New Issue
Block a user