From e46bd055944788f42fb8ada0df38a2165ecbd5ee Mon Sep 17 00:00:00 2001 From: Mahima Shanware Date: Tue, 28 Apr 2026 21:16:35 +0000 Subject: [PATCH] feat(cli): fix UserSimulator deadlock on static screens by tracking composite state --- .../cli/src/services/UserSimulator.test.ts | 39 +++++++++++++++++++ packages/cli/src/services/UserSimulator.ts | 18 +++++---- 2 files changed, 50 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/services/UserSimulator.test.ts b/packages/cli/src/services/UserSimulator.test.ts index 9e4249006d..d5fd89c2f0 100644 --- a/packages/cli/src/services/UserSimulator.test.ts +++ b/packages/cli/src/services/UserSimulator.test.ts @@ -189,4 +189,43 @@ describe('UserSimulator', () => { expect(mockMessageBus.unsubscribe).toHaveBeenCalled(); vi.useRealTimers(); }); + + it('should re-evaluate if internal tool state changes even if screen content is static', async () => { + const simulator = new UserSimulator( + mockConfig, + mockGetScreen, + mockStdinBuffer, + ); + mockGetScreen.mockReturnValue('Responding...'); + + vi.useFakeTimers(); + simulator.start(); + + // Trigger first tick + await vi.advanceTimersByTimeAsync(2000); + expect(mockContentGenerator.generateContent).toHaveBeenCalledTimes(1); + + // Trigger second tick with same screen - should skip + await vi.advanceTimersByTimeAsync(2000); + expect(mockContentGenerator.generateContent).toHaveBeenCalledTimes(1); + + // Simulate tool call update + const handler = mockMessageBus.subscribe.mock.calls[0][1]; + handler({ + type: MessageBusType.TOOL_CALLS_UPDATE, + toolCalls: [ + { + status: CoreToolCallStatus.AwaitingApproval, + request: { callId: '123', name: 'test_tool' }, + }, + ], + }); + + // Trigger third tick with same screen but new tool state - should NOT skip + await vi.advanceTimersByTimeAsync(2000); + expect(mockContentGenerator.generateContent).toHaveBeenCalledTimes(2); + + simulator.stop(); + vi.useRealTimers(); + }); }); diff --git a/packages/cli/src/services/UserSimulator.ts b/packages/cli/src/services/UserSimulator.ts index 4635ffb931..540dea801a 100644 --- a/packages/cli/src/services/UserSimulator.ts +++ b/packages/cli/src/services/UserSimulator.ts @@ -28,7 +28,7 @@ interface SimulatorResponse { export class UserSimulator { private isRunning = false; private timer: NodeJS.Timeout | null = null; - private lastScreenContent = ''; + private lastStateKey = ''; private isProcessing = false; private interactionsFile: string | null = null; @@ -131,7 +131,16 @@ export class UserSimulator { .replace(/\(\s*\)/g, '') .trim(); - if (normalizedScreen === this.lastScreenContent) return; + // Create a composite key representing the full state (Vision + Internal State) + const pendingIds = this.pendingToolCalls + .map((tc) => tc.request.callId) + .join(','); + const currentStateKey = `${normalizedScreen}::${pendingIds}`; + + if (currentStateKey === this.lastStateKey) { + return; + } + this.lastStateKey = currentStateKey; debugLogger.log( `[SIMULATOR] Screen Content Seen:\n---\n${strippedScreen}\n---`, @@ -304,7 +313,6 @@ ${strippedScreen} `[LOG] [SIMULATOR] Action History updated with: ""\n\n`, ); } - this.lastScreenContent = normalizedScreen; return; } @@ -364,8 +372,6 @@ ${strippedScreen} // Wait a bit to ensure Ink has processed the full input await new Promise((resolve) => setTimeout(resolve, 100)); - - this.lastScreenContent = normalizedScreen; } else { debugLogger.log('[SIMULATOR] Skipping (empty response)'); @@ -376,8 +382,6 @@ ${strippedScreen} `[LOG] [SIMULATOR] Action History updated with: ""\n\n`, ); } - - this.lastScreenContent = normalizedScreen; } } catch (e: unknown) { debugLogger.error('UserSimulator tick failed', e);