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,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 } =>
+11
View File
@@ -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;