diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index 91a03d6381..af2d283dc3 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -28500,7 +28500,7 @@ describe('Session', () => { }); }); - it('drops repeated duplicate provider functionCall ids after the first synthetic response', async () => { + it('records repeated duplicate provider calls without returning results', async () => { const execute = vi.fn().mockResolvedValue({ llmContent: 'should not run', returnDisplay: 'should not run', @@ -28529,6 +28529,7 @@ describe('Session', () => { ], ]), ); + const usedIds = new Set(['shell_1']); const [duplicatePart] = core.normalizeModelToolCallIds( [ { @@ -28539,7 +28540,7 @@ describe('Session', () => { }, }, ], - new Set(['shell_1']), + usedIds, new Set(), ); const duplicateCall = duplicatePart.functionCall!; @@ -28549,6 +28550,19 @@ describe('Session', () => { ).runToolCalls(new AbortController().signal, 'prompt-history-dup', [ duplicateCall, ]); + const [repeatedPart] = core.normalizeModelToolCallIds( + [ + { + functionCall: { + id: 'shell_1', + name: 'read_file', + args: { file_path: 'b.ts' }, + }, + }, + ], + usedIds, + new Set(), + ); const toolLoopState: DaemonToolLoopState = { totalToolCalls: 0, invalidToolParamErrors: new Map(), @@ -28558,13 +28572,14 @@ describe('Session', () => { repeatedToolFailureMode: 'off', repeatedToolFailureState: createRepeatedToolFailureGuardState(), }; + expect(repeatedPart.functionCall?.id).toBe('shell_1__qwen_dup_3'); const secondResult = await ( session as unknown as ToolCallInternals ).runToolCalls( new AbortController().signal, 'prompt-history-dup', [ - duplicateCall, + repeatedPart.functionCall!, { id: 'fresh_shell', name: 'read_file', args: { file_path: 'c.ts' } }, ], toolLoopState, @@ -28589,9 +28604,39 @@ describe('Session', () => { core.LoopType.GLOBAL_TOOL_CALL_DUPLICATE, ); expect(mockChatRecordingService.recordToolResult).toHaveBeenCalledTimes( - 1, + 3, ); - expect(mockClient.sessionUpdate).toHaveBeenCalledTimes(1); + expect( + mockChatRecordingService.recordToolResult.mock.calls + .slice(1) + .map(([parts, metadata]) => ({ + callId: metadata.callId, + responseId: parts[0]?.functionResponse?.id, + error: parts[0]?.functionResponse?.response?.['error'], + status: metadata.status, + executionStatus: metadata.executionStatus, + })), + ).toEqual([ + { + callId: 'shell_1__qwen_dup_3', + responseId: 'shell_1__qwen_dup_3', + error: expect.stringContaining( + 'loop detection stopped the current turn', + ), + status: 'error', + executionStatus: 'not_started', + }, + { + callId: 'fresh_shell', + responseId: 'fresh_shell', + error: expect.stringContaining( + 'loop detection stopped the current turn', + ), + status: 'error', + executionStatus: 'not_started', + }, + ]); + expect(mockClient.sessionUpdate).toHaveBeenCalledTimes(3); }); it('suppresses duplicate TodoWrite calls without emitting plan updates', async () => { diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index c60c5998bd..712b399fa2 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -9055,11 +9055,22 @@ export class Session implements SessionContext { } else { debugLogger.warn(message); } - return await finalizeRunToolResult({ + await Promise.all( + dedupedFunctionCalls.map((fc) => + recordSkippedToolCall( + fc, + LOOP_DETECTED_SKIP_MESSAGE, + false, + ToolErrorType.UNKNOWN, + ), + ), + ); + const result = await finalizeRunToolResult({ parts: [], stopAfterPermissionCancel: false, loopDetected: true, }); + return { ...result, parts: [] }; } const pushDuplicateBatch = (