feat(core): improve shell execution service reliability (#10607)

This commit is contained in:
Gal Zahavi
2025-10-10 15:14:37 -07:00
committed by GitHub
parent 37678acb1a
commit 265d39f337
4 changed files with 292 additions and 125 deletions

View File

@@ -219,7 +219,7 @@ describe('useShellCommandProcessor', () => {
vi.useRealTimers();
});
it('should throttle pending UI updates for text streams (non-interactive)', async () => {
it('should update UI for text streams (non-interactive)', async () => {
const { result } = renderProcessorHook();
act(() => {
result.current.handleShellCommand(
@@ -243,61 +243,43 @@ describe('useShellCommandProcessor', () => {
);
// Wait for the async PID update to happen.
// Call 1: Initial, Call 2: PID update
await vi.waitFor(() => {
// It's called once for initial, and once for the PID update.
expect(setPendingHistoryItemMock).toHaveBeenCalledTimes(2);
});
// Simulate rapid output
// Get the state after the PID update to feed into the stream updaters
const pidUpdateFn = setPendingHistoryItemMock.mock.calls[1][0];
const initialState = setPendingHistoryItemMock.mock.calls[0][0];
const stateAfterPid = pidUpdateFn(initialState);
// Simulate first output chunk
act(() => {
mockShellOutputCallback({
type: 'data',
chunk: 'hello',
});
});
// The count should still be 2, as throttling is in effect.
expect(setPendingHistoryItemMock).toHaveBeenCalledTimes(2);
// A UI update should have occurred.
expect(setPendingHistoryItemMock).toHaveBeenCalledTimes(3);
// Simulate more rapid output
const streamUpdateFn1 = setPendingHistoryItemMock.mock.calls[2][0];
const stateAfterStream1 = streamUpdateFn1(stateAfterPid);
expect(stateAfterStream1.tools[0].resultDisplay).toBe('hello');
// Simulate second output chunk
act(() => {
mockShellOutputCallback({
type: 'data',
chunk: ' world',
});
});
expect(setPendingHistoryItemMock).toHaveBeenCalledTimes(2);
// Another UI update should have occurred.
expect(setPendingHistoryItemMock).toHaveBeenCalledTimes(4);
// Advance time, but the update won't happen until the next event
await act(async () => {
await vi.advanceTimersByTimeAsync(OUTPUT_UPDATE_INTERVAL_MS + 1);
});
// Trigger one more event to cause the throttled update to fire.
act(() => {
mockShellOutputCallback({
type: 'data',
chunk: '',
});
});
// Now the cumulative update should have occurred.
// Call 1: Initial, Call 2: PID update, Call 3: Throttled stream update
expect(setPendingHistoryItemMock).toHaveBeenCalledTimes(3);
const streamUpdateFn = setPendingHistoryItemMock.mock.calls[2][0];
if (!streamUpdateFn || typeof streamUpdateFn !== 'function') {
throw new Error(
'setPendingHistoryItem was not called with a stream updater function',
);
}
// Get the state after the PID update to feed into the stream updater
const pidUpdateFn = setPendingHistoryItemMock.mock.calls[1][0];
const initialState = setPendingHistoryItemMock.mock.calls[0][0];
const stateAfterPid = pidUpdateFn(initialState);
const stateAfterStream = streamUpdateFn(stateAfterPid);
expect(stateAfterStream.tools[0].resultDisplay).toBe('hello world');
const streamUpdateFn2 = setPendingHistoryItemMock.mock.calls[3][0];
const stateAfterStream2 = streamUpdateFn2(stateAfterStream1);
expect(stateAfterStream2.tools[0].resultDisplay).toBe('hello world');
});
it('should show binary progress messages correctly', async () => {

View File

@@ -109,7 +109,6 @@ export const useShellCommandProcessor = (
const executeCommand = async (
resolve: (value: void | PromiseLike<void>) => void,
) => {
let lastUpdateTime = Date.now();
let cumulativeStdout: string | AnsiOutput = '';
let isBinaryStream = false;
let binaryBytesReceived = 0;
@@ -168,6 +167,7 @@ export const useShellCommandProcessor = (
typeof cumulativeStdout === 'string'
) {
cumulativeStdout += event.chunk;
shouldUpdate = true;
}
break;
case 'binary_detected':
@@ -178,6 +178,7 @@ export const useShellCommandProcessor = (
case 'binary_progress':
isBinaryStream = true;
binaryBytesReceived = event.bytesReceived;
shouldUpdate = true;
break;
default: {
throw new Error('An unhandled ShellOutputEvent was found.');
@@ -200,10 +201,7 @@ export const useShellCommandProcessor = (
}
// Throttle pending UI updates, but allow forced updates.
if (
shouldUpdate ||
Date.now() - lastUpdateTime > OUTPUT_UPDATE_INTERVAL_MS
) {
if (shouldUpdate) {
setPendingHistoryItem((prevItem) => {
if (prevItem?.type === 'tool_group') {
return {
@@ -217,7 +215,6 @@ export const useShellCommandProcessor = (
}
return prevItem;
});
lastUpdateTime = Date.now();
}
},
abortSignal,