feat: streaming shell output while commands run (#10808)
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
import type { ToolCallUpdate } from '@agentclientprotocol/sdk';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { toolNotificationEvent } from '../adapter/toolNotifications';
|
||||
|
||||
function liveOutputUpdate(params: unknown): ToolCallUpdate {
|
||||
return {
|
||||
toolCallId: 'tool-1',
|
||||
status: 'in_progress',
|
||||
_meta: {
|
||||
toolNotification: {
|
||||
type: 'live_output',
|
||||
params,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('toolNotificationEvent', () => {
|
||||
it('maps live output metadata to a tool-correlated notification', () => {
|
||||
const event = toolNotificationEvent(
|
||||
liveOutputUpdate({
|
||||
sequence: 2,
|
||||
chunks: [
|
||||
{
|
||||
stream: 'stdout',
|
||||
output: 'ready\n',
|
||||
},
|
||||
{
|
||||
stream: 'stderr',
|
||||
output: 'warning\n',
|
||||
},
|
||||
],
|
||||
truncated: false,
|
||||
})
|
||||
);
|
||||
|
||||
expect(event).toEqual({
|
||||
type: 'Notification',
|
||||
request_id: 'tool-1',
|
||||
message: {
|
||||
method: 'goose/live_output',
|
||||
params: {
|
||||
sequence: 2,
|
||||
chunks: [
|
||||
{
|
||||
stream: 'stdout',
|
||||
output: 'ready\n',
|
||||
},
|
||||
{
|
||||
stream: 'stderr',
|
||||
output: 'warning\n',
|
||||
},
|
||||
],
|
||||
truncated: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores malformed live output metadata', () => {
|
||||
expect(
|
||||
toolNotificationEvent(
|
||||
liveOutputUpdate({
|
||||
sequence: 'two',
|
||||
chunks: [],
|
||||
truncated: false,
|
||||
})
|
||||
)
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { ToolCallUpdate } from '@agentclientprotocol/sdk';
|
||||
import type { NotificationEvent } from '../../types/message';
|
||||
import type {
|
||||
LiveOutputNotificationChunk,
|
||||
LiveOutputNotificationParams,
|
||||
NotificationEvent,
|
||||
} from '../../types/message';
|
||||
import type { AcpChatStateChange } from './shared';
|
||||
import { isRecord } from './shared';
|
||||
|
||||
@@ -15,6 +19,10 @@ type ToolNotification =
|
||||
| {
|
||||
type: 'platform_event';
|
||||
params: PlatformEventParams;
|
||||
}
|
||||
| {
|
||||
type: 'live_output';
|
||||
params: LiveOutputNotificationParams;
|
||||
};
|
||||
|
||||
type LoggingMessageNotificationParams = {
|
||||
@@ -32,6 +40,14 @@ type ProgressNotificationParams = {
|
||||
|
||||
type PlatformEventParams = Record<string, unknown>;
|
||||
|
||||
function isLiveOutputChunk(value: unknown): value is LiveOutputNotificationChunk {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
(value.stream === 'stdout' || value.stream === 'stderr') &&
|
||||
typeof value.output === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
export function toolNotificationChange(
|
||||
update: ToolCallUpdate
|
||||
): Extract<AcpChatStateChange, { type: 'notification' }> | undefined {
|
||||
@@ -80,6 +96,11 @@ function parseToolNotification(meta: unknown): ToolNotification | undefined {
|
||||
return params ? { type: 'platform_event', params } : undefined;
|
||||
}
|
||||
|
||||
if (toolNotification.type === 'live_output') {
|
||||
const params = parseLiveOutputParams(toolNotification.params);
|
||||
return params ? { type: 'live_output', params } : undefined;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -116,6 +137,24 @@ function parsePlatformEventParams(value: unknown): PlatformEventParams | undefin
|
||||
return isRecord(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function parseLiveOutputParams(value: unknown): LiveOutputNotificationParams | undefined {
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
typeof value.sequence !== 'number' ||
|
||||
!Array.isArray(value.chunks) ||
|
||||
!value.chunks.every(isLiveOutputChunk) ||
|
||||
typeof value.truncated !== 'boolean'
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
sequence: value.sequence,
|
||||
chunks: value.chunks,
|
||||
truncated: value.truncated,
|
||||
};
|
||||
}
|
||||
|
||||
function toNotificationEvent(
|
||||
toolCallId: string,
|
||||
toolNotification: ToolNotification
|
||||
@@ -138,5 +177,7 @@ function notificationMethod(toolNotification: ToolNotification): string {
|
||||
return 'notifications/progress';
|
||||
case 'platform_event':
|
||||
return 'platform_event';
|
||||
case 'live_output':
|
||||
return 'goose/live_output';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 } =>
|
||||
|
||||
@@ -258,6 +258,17 @@ export type ToolConfirmationRequestContent = ToolConfirmationRequest & {
|
||||
};
|
||||
export type NotificationEvent = Extract<MessageEvent, { type: 'Notification' }>;
|
||||
|
||||
export type LiveOutputNotificationParams = {
|
||||
sequence: number;
|
||||
chunks: LiveOutputNotificationChunk[];
|
||||
truncated: boolean;
|
||||
};
|
||||
|
||||
export type LiveOutputNotificationChunk = {
|
||||
stream: 'stdout' | 'stderr';
|
||||
output: string;
|
||||
};
|
||||
|
||||
export interface ImageData {
|
||||
data: string; // base64 encoded image data
|
||||
mimeType: string;
|
||||
|
||||
Reference in New Issue
Block a user