fix: deduplicate tool_call_ids in orphaned approval recovery (#414)

This commit is contained in:
Cameron
2026-02-26 15:07:33 -08:00
committed by GitHub
parent 9b36d4fca1
commit fb820ee89a
2 changed files with 89 additions and 7 deletions

View File

@@ -170,6 +170,78 @@ describe('recoverOrphanedConversationApproval', () => {
expect(mockConversationsMessagesCreate).not.toHaveBeenCalled();
});
it('deduplicates identical tool_call_ids across multiple approval_request_messages', async () => {
// Simulate the server returning the same tool_call_id in multiple
// approval_request_messages (the root cause of #359).
mockConversationsMessagesList.mockReturnValue(mockPageIterator([
{
message_type: 'approval_request_message',
tool_calls: [{ tool_call_id: 'tc-dup', name: 'Bash' }],
run_id: 'run-dup',
id: 'msg-dup-1',
},
{
message_type: 'approval_request_message',
tool_calls: [{ tool_call_id: 'tc-dup', name: 'Bash' }],
run_id: 'run-dup',
id: 'msg-dup-2',
},
{
message_type: 'approval_request_message',
tool_calls: [{ tool_call_id: 'tc-dup', name: 'Bash' }],
run_id: 'run-dup',
id: 'msg-dup-3',
},
]));
mockRunsRetrieve.mockResolvedValue({ status: 'failed', stop_reason: 'error' });
mockConversationsMessagesCreate.mockResolvedValue({});
mockRunsList.mockReturnValue(mockPageIterator([]));
const resultPromise = recoverOrphanedConversationApproval('agent-1', 'conv-1');
await vi.advanceTimersByTimeAsync(3000);
const result = await resultPromise;
expect(result.recovered).toBe(true);
// Should only send ONE denial despite three identical approval_request_messages
expect(mockConversationsMessagesCreate).toHaveBeenCalledOnce();
const approvals = mockConversationsMessagesCreate.mock.calls[0][1].messages[0].approvals;
expect(approvals).toHaveLength(1);
expect(approvals[0].tool_call_id).toBe('tc-dup');
});
it('continues recovery if batch denial API call fails', async () => {
// Two runs with approvals -- first batch fails, second should still succeed
mockConversationsMessagesList.mockReturnValue(mockPageIterator([
{
message_type: 'approval_request_message',
tool_calls: [{ tool_call_id: 'tc-fail', name: 'Bash' }],
run_id: 'run-fail',
id: 'msg-fail',
},
{
message_type: 'approval_request_message',
tool_calls: [{ tool_call_id: 'tc-ok', name: 'Read' }],
run_id: 'run-ok',
id: 'msg-ok',
},
]));
mockRunsRetrieve.mockResolvedValue({ status: 'failed', stop_reason: 'error' });
mockConversationsMessagesCreate
.mockRejectedValueOnce(new Error('400 BadRequestError'))
.mockResolvedValueOnce({});
mockRunsList.mockReturnValue(mockPageIterator([]));
const resultPromise = recoverOrphanedConversationApproval('agent-1', 'conv-1');
await vi.advanceTimersByTimeAsync(3000);
const result = await resultPromise;
// Second run still recovered despite first failing
expect(result.recovered).toBe(true);
expect(result.details).toContain('Failed to deny');
expect(result.details).toContain('Denied 1 approval(s) from failed run run-ok');
expect(mockConversationsMessagesCreate).toHaveBeenCalledTimes(2);
});
it('reports cancel failure accurately', async () => {
mockConversationsMessagesList.mockReturnValue(mockPageIterator([
{

View File

@@ -668,6 +668,7 @@ export async function recoverOrphanedConversationApproval(
runId: string;
}
const unresolvedByRun = new Map<string, UnresolvedApproval[]>();
const seenToolCallIds = new Set<string>();
for (const msg of messages) {
if (msg.message_type !== 'approval_request_message') continue;
@@ -678,6 +679,9 @@ export async function recoverOrphanedConversationApproval(
for (const tc of toolCalls) {
if (!tc.tool_call_id || resolvedToolCalls.has(tc.tool_call_id)) continue;
// Skip duplicate tool_call_ids across multiple approval_request_messages
if (seenToolCallIds.has(tc.tool_call_id)) continue;
seenToolCallIds.add(tc.tool_call_id);
const key = runId || 'unknown';
if (!unresolvedByRun.has(key)) unresolvedByRun.set(key, []);
@@ -731,6 +735,7 @@ export async function recoverOrphanedConversationApproval(
reason: `Auto-denied: originating run was ${status}/${stopReason}`,
}));
try {
await client.conversations.messages.create(conversationId, {
messages: [{
type: 'approval',
@@ -738,6 +743,11 @@ export async function recoverOrphanedConversationApproval(
}],
streaming: false,
});
} catch (batchError) {
log.warn(`Failed to submit approval denial batch for run ${runId} (${approvals.length} tool call(s)):`, batchError);
details.push(`Failed to deny ${approvals.length} approval(s) from run ${runId}`);
continue;
}
// The denial triggers a new agent run server-side. Wait for it to
// settle before returning, otherwise the caller retries immediately