mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-14 05:42:54 -07:00
fix(a2a-server): resolve tool approval race condition and improve status reporting (#26479)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
This commit is contained in:
@@ -66,6 +66,7 @@ describe('Task Event-Driven Scheduler', () => {
|
|||||||
handler({
|
handler({
|
||||||
type: MessageBusType.TOOL_CALLS_UPDATE,
|
type: MessageBusType.TOOL_CALLS_UPDATE,
|
||||||
toolCalls: [toolCall],
|
toolCalls: [toolCall],
|
||||||
|
schedulerId: 'task-id',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockEventBus.publish).toHaveBeenCalledWith(
|
expect(mockEventBus.publish).toHaveBeenCalledWith(
|
||||||
@@ -106,6 +107,7 @@ describe('Task Event-Driven Scheduler', () => {
|
|||||||
handler({
|
handler({
|
||||||
type: MessageBusType.TOOL_CALLS_UPDATE,
|
type: MessageBusType.TOOL_CALLS_UPDATE,
|
||||||
toolCalls: [toolCall],
|
toolCalls: [toolCall],
|
||||||
|
schedulerId: 'task-id',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Simulate A2A client confirmation
|
// Simulate A2A client confirmation
|
||||||
@@ -148,7 +150,11 @@ describe('Task Event-Driven Scheduler', () => {
|
|||||||
const handler = (messageBus.subscribe as Mock).mock.calls.find(
|
const handler = (messageBus.subscribe as Mock).mock.calls.find(
|
||||||
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
|
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
|
||||||
)?.[1];
|
)?.[1];
|
||||||
handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall] });
|
handler({
|
||||||
|
type: MessageBusType.TOOL_CALLS_UPDATE,
|
||||||
|
toolCalls: [toolCall],
|
||||||
|
schedulerId: 'task-id',
|
||||||
|
});
|
||||||
|
|
||||||
// Simulate Rejection (Cancel)
|
// Simulate Rejection (Cancel)
|
||||||
const handled = await (
|
const handled = await (
|
||||||
@@ -174,7 +180,11 @@ describe('Task Event-Driven Scheduler', () => {
|
|||||||
correlationId: 'corr-2',
|
correlationId: 'corr-2',
|
||||||
confirmationDetails: { type: 'info', title: 'test', prompt: 'test' },
|
confirmationDetails: { type: 'info', title: 'test', prompt: 'test' },
|
||||||
};
|
};
|
||||||
handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall2] });
|
handler({
|
||||||
|
type: MessageBusType.TOOL_CALLS_UPDATE,
|
||||||
|
toolCalls: [toolCall2],
|
||||||
|
schedulerId: 'task-id',
|
||||||
|
});
|
||||||
|
|
||||||
// Simulate ModifyWithEditor
|
// Simulate ModifyWithEditor
|
||||||
const handled2 = await (
|
const handled2 = await (
|
||||||
@@ -215,7 +225,11 @@ describe('Task Event-Driven Scheduler', () => {
|
|||||||
const handler = (messageBus.subscribe as Mock).mock.calls.find(
|
const handler = (messageBus.subscribe as Mock).mock.calls.find(
|
||||||
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
|
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
|
||||||
)?.[1];
|
)?.[1];
|
||||||
handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall] });
|
handler({
|
||||||
|
type: MessageBusType.TOOL_CALLS_UPDATE,
|
||||||
|
toolCalls: [toolCall],
|
||||||
|
schedulerId: 'task-id',
|
||||||
|
});
|
||||||
|
|
||||||
// Simulate ProceedOnce for MCP
|
// Simulate ProceedOnce for MCP
|
||||||
const handled = await (
|
const handled = await (
|
||||||
@@ -255,7 +269,11 @@ describe('Task Event-Driven Scheduler', () => {
|
|||||||
const handler = (messageBus.subscribe as Mock).mock.calls.find(
|
const handler = (messageBus.subscribe as Mock).mock.calls.find(
|
||||||
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
|
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
|
||||||
)?.[1];
|
)?.[1];
|
||||||
handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall] });
|
handler({
|
||||||
|
type: MessageBusType.TOOL_CALLS_UPDATE,
|
||||||
|
toolCalls: [toolCall],
|
||||||
|
schedulerId: 'task-id',
|
||||||
|
});
|
||||||
|
|
||||||
const handled = await (
|
const handled = await (
|
||||||
task as unknown as {
|
task as unknown as {
|
||||||
@@ -294,7 +312,11 @@ describe('Task Event-Driven Scheduler', () => {
|
|||||||
const handler = (messageBus.subscribe as Mock).mock.calls.find(
|
const handler = (messageBus.subscribe as Mock).mock.calls.find(
|
||||||
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
|
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
|
||||||
)?.[1];
|
)?.[1];
|
||||||
handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall] });
|
handler({
|
||||||
|
type: MessageBusType.TOOL_CALLS_UPDATE,
|
||||||
|
toolCalls: [toolCall],
|
||||||
|
schedulerId: 'task-id',
|
||||||
|
});
|
||||||
|
|
||||||
const handled = await (
|
const handled = await (
|
||||||
task as unknown as {
|
task as unknown as {
|
||||||
@@ -333,7 +355,11 @@ describe('Task Event-Driven Scheduler', () => {
|
|||||||
const handler = (messageBus.subscribe as Mock).mock.calls.find(
|
const handler = (messageBus.subscribe as Mock).mock.calls.find(
|
||||||
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
|
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
|
||||||
)?.[1];
|
)?.[1];
|
||||||
handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall] });
|
handler({
|
||||||
|
type: MessageBusType.TOOL_CALLS_UPDATE,
|
||||||
|
toolCalls: [toolCall],
|
||||||
|
schedulerId: 'task-id',
|
||||||
|
});
|
||||||
|
|
||||||
const handled = await (
|
const handled = await (
|
||||||
task as unknown as {
|
task as unknown as {
|
||||||
@@ -376,7 +402,11 @@ describe('Task Event-Driven Scheduler', () => {
|
|||||||
const handler = (yoloMessageBus.subscribe as Mock).mock.calls.find(
|
const handler = (yoloMessageBus.subscribe as Mock).mock.calls.find(
|
||||||
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
|
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
|
||||||
)?.[1];
|
)?.[1];
|
||||||
handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall] });
|
handler({
|
||||||
|
type: MessageBusType.TOOL_CALLS_UPDATE,
|
||||||
|
toolCalls: [toolCall],
|
||||||
|
schedulerId: 'task-id',
|
||||||
|
});
|
||||||
|
|
||||||
// Should NOT auto-publish ProceedOnce anymore, because PolicyEngine handles it directly
|
// Should NOT auto-publish ProceedOnce anymore, because PolicyEngine handles it directly
|
||||||
expect(yoloMessageBus.publish).not.toHaveBeenCalledWith(
|
expect(yoloMessageBus.publish).not.toHaveBeenCalledWith(
|
||||||
@@ -419,6 +449,7 @@ describe('Task Event-Driven Scheduler', () => {
|
|||||||
handler({
|
handler({
|
||||||
type: MessageBusType.TOOL_CALLS_UPDATE,
|
type: MessageBusType.TOOL_CALLS_UPDATE,
|
||||||
toolCalls: [toolCall],
|
toolCalls: [toolCall],
|
||||||
|
schedulerId: 'task-id',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Should publish artifact update for output
|
// Should publish artifact update for output
|
||||||
@@ -453,7 +484,11 @@ describe('Task Event-Driven Scheduler', () => {
|
|||||||
const handler = (messageBus.subscribe as Mock).mock.calls.find(
|
const handler = (messageBus.subscribe as Mock).mock.calls.find(
|
||||||
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
|
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
|
||||||
)?.[1];
|
)?.[1];
|
||||||
handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall] });
|
handler({
|
||||||
|
type: MessageBusType.TOOL_CALLS_UPDATE,
|
||||||
|
toolCalls: [toolCall],
|
||||||
|
schedulerId: 'task-id',
|
||||||
|
});
|
||||||
|
|
||||||
// The tool should be complete and registered appropriately, eventually
|
// The tool should be complete and registered appropriately, eventually
|
||||||
// triggering the toolCompletionPromise resolution when all clear.
|
// triggering the toolCompletionPromise resolution when all clear.
|
||||||
@@ -533,6 +568,7 @@ describe('Task Event-Driven Scheduler', () => {
|
|||||||
handler({
|
handler({
|
||||||
type: MessageBusType.TOOL_CALLS_UPDATE,
|
type: MessageBusType.TOOL_CALLS_UPDATE,
|
||||||
toolCalls: [toolCall1, toolCall2],
|
toolCalls: [toolCall1, toolCall2],
|
||||||
|
schedulerId: 'task-id',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Confirm first tool call
|
// Confirm first tool call
|
||||||
@@ -600,6 +636,7 @@ describe('Task Event-Driven Scheduler', () => {
|
|||||||
handler({
|
handler({
|
||||||
type: MessageBusType.TOOL_CALLS_UPDATE,
|
type: MessageBusType.TOOL_CALLS_UPDATE,
|
||||||
toolCalls: [toolCall1, toolCall2],
|
toolCalls: [toolCall1, toolCall2],
|
||||||
|
schedulerId: 'task-id',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Should NOT transition to input-required yet
|
// Should NOT transition to input-required yet
|
||||||
@@ -621,6 +658,7 @@ describe('Task Event-Driven Scheduler', () => {
|
|||||||
handler({
|
handler({
|
||||||
type: MessageBusType.TOOL_CALLS_UPDATE,
|
type: MessageBusType.TOOL_CALLS_UPDATE,
|
||||||
toolCalls: [toolCall1Complete, toolCall2],
|
toolCalls: [toolCall1Complete, toolCall2],
|
||||||
|
schedulerId: 'task-id',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Now it should transition
|
// Now it should transition
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ import {
|
|||||||
type ToolCallRequestInfo,
|
type ToolCallRequestInfo,
|
||||||
type GitService,
|
type GitService,
|
||||||
type CompletedToolCall,
|
type CompletedToolCall,
|
||||||
|
type ToolCall,
|
||||||
|
type ToolCallsUpdateMessage,
|
||||||
|
MessageBusType,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
import { createMockConfig } from '../utils/testing_utils.js';
|
import { createMockConfig } from '../utils/testing_utils.js';
|
||||||
import type { ExecutionEventBus, RequestContext } from '@a2a-js/sdk/server';
|
import type { ExecutionEventBus, RequestContext } from '@a2a-js/sdk/server';
|
||||||
@@ -460,4 +463,204 @@ describe('Task', () => {
|
|||||||
expect(task.currentPromptId).toBe(expectedPromptId2);
|
expect(task.currentPromptId).toBe(expectedPromptId2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Race Condition Fix', () => {
|
||||||
|
const mockConfig = createMockConfig();
|
||||||
|
const mockEventBus: ExecutionEventBus = {
|
||||||
|
publish: vi.fn(),
|
||||||
|
on: vi.fn(),
|
||||||
|
off: vi.fn(),
|
||||||
|
once: vi.fn(),
|
||||||
|
removeAllListeners: vi.fn(),
|
||||||
|
finished: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT transition to input-required if a tool is still validating', async () => {
|
||||||
|
// @ts-expect-error - Calling private constructor
|
||||||
|
const task = new Task(
|
||||||
|
'task-id',
|
||||||
|
'context-id',
|
||||||
|
mockConfig as Config,
|
||||||
|
mockEventBus,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Manually register two tool calls
|
||||||
|
task['_registerToolCall']('tool-1', 'awaiting_approval');
|
||||||
|
task['_registerToolCall']('tool-2', 'validating');
|
||||||
|
|
||||||
|
// Call checkInputRequiredState (private)
|
||||||
|
task['checkInputRequiredState']();
|
||||||
|
|
||||||
|
// Verify task state did NOT change to input-required
|
||||||
|
expect(task.taskState).not.toBe('input-required');
|
||||||
|
expect(mockEventBus.publish).not.toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
status: expect.objectContaining({ state: 'input-required' }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should transition to input-required if all active tools are awaiting approval', async () => {
|
||||||
|
// @ts-expect-error - Calling private constructor
|
||||||
|
const task = new Task(
|
||||||
|
'task-id',
|
||||||
|
'context-id',
|
||||||
|
mockConfig as Config,
|
||||||
|
mockEventBus,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Transition from submitted to working first to simulate normal flow
|
||||||
|
task.taskState = 'working';
|
||||||
|
|
||||||
|
// Manually register tool calls
|
||||||
|
task['_registerToolCall']('tool-1', 'awaiting_approval');
|
||||||
|
|
||||||
|
// Call checkInputRequiredState
|
||||||
|
task['checkInputRequiredState']();
|
||||||
|
|
||||||
|
// Verify task state changed to input-required
|
||||||
|
expect(task.taskState).toBe('input-required');
|
||||||
|
expect(mockEventBus.publish).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
status: expect.objectContaining({ state: 'input-required' }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handleEventDrivenToolCallsUpdate should ignore events for other schedulers', async () => {
|
||||||
|
// @ts-expect-error - Calling private constructor
|
||||||
|
const task = new Task(
|
||||||
|
'task-id',
|
||||||
|
'context-id',
|
||||||
|
mockConfig as Config,
|
||||||
|
mockEventBus,
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleEventDrivenToolCallSpy = vi.spyOn(
|
||||||
|
task as unknown as {
|
||||||
|
handleEventDrivenToolCall: Task['handleEventDrivenToolCall'];
|
||||||
|
},
|
||||||
|
'handleEventDrivenToolCall',
|
||||||
|
);
|
||||||
|
|
||||||
|
const otherEvent: ToolCallsUpdateMessage = {
|
||||||
|
type: MessageBusType.TOOL_CALLS_UPDATE,
|
||||||
|
toolCalls: [
|
||||||
|
{ request: { callId: '1' }, status: 'executing' } as ToolCall,
|
||||||
|
],
|
||||||
|
schedulerId: 'other-task-id',
|
||||||
|
};
|
||||||
|
|
||||||
|
task['handleEventDrivenToolCallsUpdate'](otherEvent);
|
||||||
|
|
||||||
|
expect(handleEventDrivenToolCallSpy).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
const ownEvent: ToolCallsUpdateMessage = {
|
||||||
|
type: MessageBusType.TOOL_CALLS_UPDATE,
|
||||||
|
toolCalls: [
|
||||||
|
{ request: { callId: '1' }, status: 'executing' } as ToolCall,
|
||||||
|
],
|
||||||
|
schedulerId: 'task-id',
|
||||||
|
};
|
||||||
|
|
||||||
|
task['handleEventDrivenToolCallsUpdate'](ownEvent);
|
||||||
|
|
||||||
|
expect(handleEventDrivenToolCallSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Serialization and Mapping', () => {
|
||||||
|
it('should map internal "validating" status to "scheduled" for the client and include outcome', async () => {
|
||||||
|
const mockConfig = createMockConfig();
|
||||||
|
const mockEventBus: ExecutionEventBus = {
|
||||||
|
publish: vi.fn(),
|
||||||
|
on: vi.fn(),
|
||||||
|
off: vi.fn(),
|
||||||
|
once: vi.fn(),
|
||||||
|
removeAllListeners: vi.fn(),
|
||||||
|
finished: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// @ts-expect-error - Calling private constructor
|
||||||
|
const task = new Task(
|
||||||
|
'task-id',
|
||||||
|
'context-id',
|
||||||
|
mockConfig as Config,
|
||||||
|
mockEventBus,
|
||||||
|
);
|
||||||
|
|
||||||
|
const mockToolCall = {
|
||||||
|
request: { callId: 'tool-1' },
|
||||||
|
status: 'validating',
|
||||||
|
outcome: 'accepted',
|
||||||
|
tool: { name: 'test-tool' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const message = task['toolStatusMessage'](
|
||||||
|
mockToolCall as unknown as ToolCall,
|
||||||
|
'task-id',
|
||||||
|
'context-id',
|
||||||
|
);
|
||||||
|
const serialized = (
|
||||||
|
message.parts![0] as {
|
||||||
|
data: { status: string; outcome: string };
|
||||||
|
}
|
||||||
|
).data;
|
||||||
|
|
||||||
|
expect(serialized.status).toBe('scheduled');
|
||||||
|
expect(serialized.outcome).toBe('accepted');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly detect changes when status or outcome changes', async () => {
|
||||||
|
const mockConfig = createMockConfig();
|
||||||
|
const mockEventBus: ExecutionEventBus = {
|
||||||
|
publish: vi.fn(),
|
||||||
|
on: vi.fn(),
|
||||||
|
off: vi.fn(),
|
||||||
|
once: vi.fn(),
|
||||||
|
removeAllListeners: vi.fn(),
|
||||||
|
finished: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// @ts-expect-error - Calling private constructor
|
||||||
|
const task = new Task(
|
||||||
|
'task-id',
|
||||||
|
'context-id',
|
||||||
|
mockConfig as Config,
|
||||||
|
mockEventBus,
|
||||||
|
);
|
||||||
|
|
||||||
|
const toolCall1 = {
|
||||||
|
request: { callId: 'tool-1' },
|
||||||
|
status: 'awaiting_approval',
|
||||||
|
};
|
||||||
|
|
||||||
|
// First update - should trigger change
|
||||||
|
const changed1 = task['handleEventDrivenToolCall'](
|
||||||
|
toolCall1 as unknown as ToolCall,
|
||||||
|
);
|
||||||
|
expect(changed1).toBe(true);
|
||||||
|
|
||||||
|
// Second update with same status - should NOT trigger change
|
||||||
|
const changed2 = task['handleEventDrivenToolCall'](
|
||||||
|
toolCall1 as unknown as ToolCall,
|
||||||
|
);
|
||||||
|
expect(changed2).toBe(false);
|
||||||
|
|
||||||
|
// Update with new outcome - SHOULD trigger change
|
||||||
|
const toolCall2 = {
|
||||||
|
request: { callId: 'tool-1' },
|
||||||
|
status: 'awaiting_approval',
|
||||||
|
outcome: 'accepted',
|
||||||
|
};
|
||||||
|
const changed3 = task['handleEventDrivenToolCall'](
|
||||||
|
toolCall2 as unknown as ToolCall,
|
||||||
|
);
|
||||||
|
expect(changed3).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
GeminiEventType,
|
GeminiEventType,
|
||||||
ToolConfirmationOutcome,
|
ToolConfirmationOutcome,
|
||||||
ApprovalMode,
|
ApprovalMode,
|
||||||
|
CoreToolCallStatus,
|
||||||
getAllMCPServerStatuses,
|
getAllMCPServerStatuses,
|
||||||
MCPServerStatus,
|
MCPServerStatus,
|
||||||
isNodeError,
|
isNodeError,
|
||||||
@@ -95,6 +96,8 @@ export class Task {
|
|||||||
|
|
||||||
// For tool waiting logic
|
// For tool waiting logic
|
||||||
private pendingToolCalls: Map<string, string> = new Map(); //toolCallId --> status
|
private pendingToolCalls: Map<string, string> = new Map(); //toolCallId --> status
|
||||||
|
private pendingOutcomes: Map<string, ToolConfirmationOutcome | undefined> =
|
||||||
|
new Map(); // toolCallId --> outcome
|
||||||
private toolsAlreadyConfirmed: Set<string> = new Set();
|
private toolsAlreadyConfirmed: Set<string> = new Set();
|
||||||
private toolCompletionPromise?: Promise<void>;
|
private toolCompletionPromise?: Promise<void>;
|
||||||
private toolCompletionNotifier?: {
|
private toolCompletionNotifier?: {
|
||||||
@@ -413,7 +416,10 @@ export class Task {
|
|||||||
private handleEventDrivenToolCallsUpdate(
|
private handleEventDrivenToolCallsUpdate(
|
||||||
event: ToolCallsUpdateMessage,
|
event: ToolCallsUpdateMessage,
|
||||||
): void {
|
): void {
|
||||||
if (event.type !== MessageBusType.TOOL_CALLS_UPDATE) {
|
if (
|
||||||
|
event.type !== MessageBusType.TOOL_CALLS_UPDATE ||
|
||||||
|
event.schedulerId !== this.id
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -426,7 +432,7 @@ export class Task {
|
|||||||
this.checkInputRequiredState();
|
this.checkInputRequiredState();
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleEventDrivenToolCall(tc: ToolCall): void {
|
private handleEventDrivenToolCall(tc: ToolCall): boolean {
|
||||||
const callId = tc.request.callId;
|
const callId = tc.request.callId;
|
||||||
|
|
||||||
// Do not process events for tools that have already been finalized.
|
// Do not process events for tools that have already been finalized.
|
||||||
@@ -436,11 +442,16 @@ export class Task {
|
|||||||
this.processedToolCallIds.has(callId) ||
|
this.processedToolCallIds.has(callId) ||
|
||||||
this.completedToolCalls.some((c) => c.request.callId === callId)
|
this.completedToolCalls.some((c) => c.request.callId === callId)
|
||||||
) {
|
) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const previousStatus = this.pendingToolCalls.get(callId);
|
const previousStatus = this.pendingToolCalls.get(callId);
|
||||||
const hasChanged = previousStatus !== tc.status;
|
const previousOutcome = this.pendingOutcomes.get(callId);
|
||||||
|
const hasChanged =
|
||||||
|
previousStatus !== tc.status || previousOutcome !== tc.outcome;
|
||||||
|
|
||||||
|
// Update outcome tracking
|
||||||
|
this.pendingOutcomes.set(callId, tc.outcome);
|
||||||
|
|
||||||
// 1. Handle Output
|
// 1. Handle Output
|
||||||
if (tc.status === 'executing' && tc.liveOutput) {
|
if (tc.status === 'executing' && tc.liveOutput) {
|
||||||
@@ -454,6 +465,7 @@ export class Task {
|
|||||||
tc.status === 'cancelled'
|
tc.status === 'cancelled'
|
||||||
) {
|
) {
|
||||||
this.toolsAlreadyConfirmed.delete(callId);
|
this.toolsAlreadyConfirmed.delete(callId);
|
||||||
|
this.pendingOutcomes.delete(callId);
|
||||||
if (hasChanged) {
|
if (hasChanged) {
|
||||||
logger.info(
|
logger.info(
|
||||||
`[Task] Tool call ${callId} completed with status: ${tc.status}`,
|
`[Task] Tool call ${callId} completed with status: ${tc.status}`,
|
||||||
@@ -496,6 +508,8 @@ export class Task {
|
|||||||
);
|
);
|
||||||
this.eventBus?.publish(statusUpdate);
|
this.eventBus?.publish(statusUpdate);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return hasChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
private checkInputRequiredState(): void {
|
private checkInputRequiredState(): void {
|
||||||
@@ -508,12 +522,14 @@ export class Task {
|
|||||||
let isExecuting = false;
|
let isExecuting = false;
|
||||||
|
|
||||||
for (const [callId, status] of this.pendingToolCalls.entries()) {
|
for (const [callId, status] of this.pendingToolCalls.entries()) {
|
||||||
if (status === 'executing' || status === 'scheduled') {
|
if (
|
||||||
isExecuting = true;
|
status === CoreToolCallStatus.Executing ||
|
||||||
} else if (
|
status === CoreToolCallStatus.Scheduled ||
|
||||||
status === 'awaiting_approval' &&
|
status === CoreToolCallStatus.Validating ||
|
||||||
!this.toolsAlreadyConfirmed.has(callId)
|
this.toolsAlreadyConfirmed.has(callId)
|
||||||
) {
|
) {
|
||||||
|
isExecuting = true;
|
||||||
|
} else if (status === CoreToolCallStatus.AwaitingApproval) {
|
||||||
isAwaitingApproval = true;
|
isAwaitingApproval = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -574,8 +590,14 @@ export class Task {
|
|||||||
'confirmationDetails',
|
'confirmationDetails',
|
||||||
'liveOutput',
|
'liveOutput',
|
||||||
'response',
|
'response',
|
||||||
|
'outcome',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Map internal 'validating' status to 'scheduled' for the client
|
||||||
|
if (serializableToolCall.status === CoreToolCallStatus.Validating) {
|
||||||
|
serializableToolCall.status = CoreToolCallStatus.Scheduled;
|
||||||
|
}
|
||||||
|
|
||||||
if (tc.tool) {
|
if (tc.tool) {
|
||||||
const toolFields = this._pickFields(
|
const toolFields = this._pickFields(
|
||||||
tc.tool,
|
tc.tool,
|
||||||
|
|||||||
@@ -228,7 +228,7 @@ describe('E2E Tests', () => {
|
|||||||
expect(toolCallUpdateEvent.status.message?.parts).toMatchObject([
|
expect(toolCallUpdateEvent.status.message?.parts).toMatchObject([
|
||||||
{
|
{
|
||||||
data: {
|
data: {
|
||||||
status: 'validating',
|
status: 'scheduled',
|
||||||
request: { callId: 'test-call-id' },
|
request: { callId: 'test-call-id' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -330,7 +330,7 @@ describe('E2E Tests', () => {
|
|||||||
expect(toolCallValidateEvent1.status.message?.parts).toMatchObject([
|
expect(toolCallValidateEvent1.status.message?.parts).toMatchObject([
|
||||||
{
|
{
|
||||||
data: {
|
data: {
|
||||||
status: 'validating',
|
status: 'scheduled',
|
||||||
request: { callId: 'test-call-id-1' },
|
request: { callId: 'test-call-id-1' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -352,7 +352,7 @@ describe('E2E Tests', () => {
|
|||||||
kind: 'state-change',
|
kind: 'state-change',
|
||||||
});
|
});
|
||||||
|
|
||||||
// 4. Tool 1 is validating.
|
// 4. Tool 1 is scheduled.
|
||||||
const toolCallUpdate1 = events[3].result as TaskStatusUpdateEvent;
|
const toolCallUpdate1 = events[3].result as TaskStatusUpdateEvent;
|
||||||
expect(toolCallUpdate1.metadata?.['coderAgent']).toMatchObject({
|
expect(toolCallUpdate1.metadata?.['coderAgent']).toMatchObject({
|
||||||
kind: 'tool-call-update',
|
kind: 'tool-call-update',
|
||||||
@@ -361,12 +361,12 @@ describe('E2E Tests', () => {
|
|||||||
{
|
{
|
||||||
data: {
|
data: {
|
||||||
request: { callId: 'test-call-id-1' },
|
request: { callId: 'test-call-id-1' },
|
||||||
status: 'validating',
|
status: 'scheduled',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 5. Tool 2 is validating.
|
// 5. Tool 2 is scheduled.
|
||||||
const toolCallUpdate2 = events[4].result as TaskStatusUpdateEvent;
|
const toolCallUpdate2 = events[4].result as TaskStatusUpdateEvent;
|
||||||
expect(toolCallUpdate2.metadata?.['coderAgent']).toMatchObject({
|
expect(toolCallUpdate2.metadata?.['coderAgent']).toMatchObject({
|
||||||
kind: 'tool-call-update',
|
kind: 'tool-call-update',
|
||||||
@@ -375,17 +375,17 @@ describe('E2E Tests', () => {
|
|||||||
{
|
{
|
||||||
data: {
|
data: {
|
||||||
request: { callId: 'test-call-id-2' },
|
request: { callId: 'test-call-id-2' },
|
||||||
status: 'validating',
|
status: 'scheduled',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 6. Tool 1 is awaiting approval.
|
// 6. Tool 1 is awaiting approval.
|
||||||
const toolCallAwaitEvent = events[5].result as TaskStatusUpdateEvent;
|
const toolCallAwaitEvent1 = events[5].result as TaskStatusUpdateEvent;
|
||||||
expect(toolCallAwaitEvent.metadata?.['coderAgent']).toMatchObject({
|
expect(toolCallAwaitEvent1.metadata?.['coderAgent']).toMatchObject({
|
||||||
kind: 'tool-call-confirmation',
|
kind: 'tool-call-confirmation',
|
||||||
});
|
});
|
||||||
expect(toolCallAwaitEvent.status.message?.parts).toMatchObject([
|
expect(toolCallAwaitEvent1.status.message?.parts).toMatchObject([
|
||||||
{
|
{
|
||||||
data: {
|
data: {
|
||||||
request: { callId: 'test-call-id-1' },
|
request: { callId: 'test-call-id-1' },
|
||||||
@@ -394,14 +394,28 @@ describe('E2E Tests', () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 7. The final event is "input-required".
|
// 7. Tool 2 is awaiting approval.
|
||||||
const finalEvent = events[6].result as TaskStatusUpdateEvent;
|
const toolCallAwaitEvent2 = events[6].result as TaskStatusUpdateEvent;
|
||||||
|
expect(toolCallAwaitEvent2.metadata?.['coderAgent']).toMatchObject({
|
||||||
|
kind: 'tool-call-confirmation',
|
||||||
|
});
|
||||||
|
expect(toolCallAwaitEvent2.status.message?.parts).toMatchObject([
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
request: { callId: 'test-call-id-2' },
|
||||||
|
status: 'awaiting_approval',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 8. The final event is "input-required".
|
||||||
|
const finalEvent = events[7].result as TaskStatusUpdateEvent;
|
||||||
expect(finalEvent.final).toBe(true);
|
expect(finalEvent.final).toBe(true);
|
||||||
expect(finalEvent.status.state).toBe('input-required');
|
expect(finalEvent.status.state).toBe('input-required');
|
||||||
|
|
||||||
// The scheduler now waits for approval, so no more events are sent.
|
// The scheduler now waits for approval, so no more events are sent.
|
||||||
assertUniqueFinalEventIsLast(events);
|
assertUniqueFinalEventIsLast(events);
|
||||||
expect(events.length).toBe(7);
|
expect(events.length).toBe(8);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle multiple tool calls sequentially in YOLO mode', async () => {
|
it('should handle multiple tool calls sequentially in YOLO mode', async () => {
|
||||||
@@ -499,7 +513,7 @@ describe('E2E Tests', () => {
|
|||||||
// Tool 1 Lifecycle
|
// Tool 1 Lifecycle
|
||||||
{
|
{
|
||||||
kind: 'tool-call-update',
|
kind: 'tool-call-update',
|
||||||
status: 'validating',
|
status: 'scheduled',
|
||||||
callId: 'test-call-id-1',
|
callId: 'test-call-id-1',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -520,7 +534,7 @@ describe('E2E Tests', () => {
|
|||||||
// Tool 2 Lifecycle
|
// Tool 2 Lifecycle
|
||||||
{
|
{
|
||||||
kind: 'tool-call-update',
|
kind: 'tool-call-update',
|
||||||
status: 'validating',
|
status: 'scheduled',
|
||||||
callId: 'test-call-id-2',
|
callId: 'test-call-id-2',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -603,26 +617,40 @@ describe('E2E Tests', () => {
|
|||||||
expect(workingEvent2.kind).toBe('status-update');
|
expect(workingEvent2.kind).toBe('status-update');
|
||||||
expect(workingEvent2.status.state).toBe('working');
|
expect(workingEvent2.status.state).toBe('working');
|
||||||
|
|
||||||
// Status update: tool-call-update (validating)
|
// Status update: tool-call-update (scheduled)
|
||||||
const validatingEvent = events[3].result as TaskStatusUpdateEvent;
|
const scheduledEvent1 = events[3].result as TaskStatusUpdateEvent;
|
||||||
expect(validatingEvent.metadata?.['coderAgent']).toMatchObject({
|
expect(scheduledEvent1.metadata?.['coderAgent']).toMatchObject({
|
||||||
kind: 'tool-call-update',
|
kind: 'tool-call-update',
|
||||||
});
|
});
|
||||||
expect(validatingEvent.status.message?.parts).toMatchObject([
|
expect(scheduledEvent1.status.message?.parts).toMatchObject([
|
||||||
{
|
{
|
||||||
data: {
|
data: {
|
||||||
status: 'validating',
|
status: 'scheduled',
|
||||||
request: { callId: 'test-call-id-no-approval' },
|
request: { callId: 'test-call-id-no-approval' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Status update: tool-call-update (scheduled)
|
// Status update: tool-call-update (scheduled)
|
||||||
const scheduledEvent = events[4].result as TaskStatusUpdateEvent;
|
const scheduledEvent2 = events[4].result as TaskStatusUpdateEvent;
|
||||||
expect(scheduledEvent.metadata?.['coderAgent']).toMatchObject({
|
expect(scheduledEvent2.metadata?.['coderAgent']).toMatchObject({
|
||||||
kind: 'tool-call-update',
|
kind: 'tool-call-update',
|
||||||
});
|
});
|
||||||
expect(scheduledEvent.status.message?.parts).toMatchObject([
|
expect(scheduledEvent2.status.message?.parts).toMatchObject([
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
status: 'scheduled',
|
||||||
|
request: { callId: 'test-call-id-no-approval' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Status update: tool-call-update (scheduled)
|
||||||
|
const scheduledEvent3 = events[5].result as TaskStatusUpdateEvent;
|
||||||
|
expect(scheduledEvent3.metadata?.['coderAgent']).toMatchObject({
|
||||||
|
kind: 'tool-call-update',
|
||||||
|
});
|
||||||
|
expect(scheduledEvent3.status.message?.parts).toMatchObject([
|
||||||
{
|
{
|
||||||
data: {
|
data: {
|
||||||
status: 'scheduled',
|
status: 'scheduled',
|
||||||
@@ -632,7 +660,7 @@ describe('E2E Tests', () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// Status update: tool-call-update (executing)
|
// Status update: tool-call-update (executing)
|
||||||
const executingEvent = events[5].result as TaskStatusUpdateEvent;
|
const executingEvent = events[6].result as TaskStatusUpdateEvent;
|
||||||
expect(executingEvent.metadata?.['coderAgent']).toMatchObject({
|
expect(executingEvent.metadata?.['coderAgent']).toMatchObject({
|
||||||
kind: 'tool-call-update',
|
kind: 'tool-call-update',
|
||||||
});
|
});
|
||||||
@@ -646,7 +674,7 @@ describe('E2E Tests', () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// Status update: tool-call-update (success)
|
// Status update: tool-call-update (success)
|
||||||
const successEvent = events[6].result as TaskStatusUpdateEvent;
|
const successEvent = events[7].result as TaskStatusUpdateEvent;
|
||||||
expect(successEvent.metadata?.['coderAgent']).toMatchObject({
|
expect(successEvent.metadata?.['coderAgent']).toMatchObject({
|
||||||
kind: 'tool-call-update',
|
kind: 'tool-call-update',
|
||||||
});
|
});
|
||||||
@@ -660,12 +688,12 @@ describe('E2E Tests', () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// Status update: working (before sending tool result to LLM)
|
// Status update: working (before sending tool result to LLM)
|
||||||
const workingEvent3 = events[7].result as TaskStatusUpdateEvent;
|
const workingEvent3 = events[8].result as TaskStatusUpdateEvent;
|
||||||
expect(workingEvent3.kind).toBe('status-update');
|
expect(workingEvent3.kind).toBe('status-update');
|
||||||
expect(workingEvent3.status.state).toBe('working');
|
expect(workingEvent3.status.state).toBe('working');
|
||||||
|
|
||||||
// Status update: text-content (final LLM response)
|
// Status update: text-content (final LLM response)
|
||||||
const textContentEvent = events[8].result as TaskStatusUpdateEvent;
|
const textContentEvent = events[9].result as TaskStatusUpdateEvent;
|
||||||
expect(textContentEvent.metadata?.['coderAgent']).toMatchObject({
|
expect(textContentEvent.metadata?.['coderAgent']).toMatchObject({
|
||||||
kind: 'text-content',
|
kind: 'text-content',
|
||||||
});
|
});
|
||||||
@@ -674,7 +702,7 @@ describe('E2E Tests', () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
assertUniqueFinalEventIsLast(events);
|
assertUniqueFinalEventIsLast(events);
|
||||||
expect(events.length).toBe(10);
|
expect(events.length).toBe(11);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should bypass tool approval in YOLO mode', async () => {
|
it('should bypass tool approval in YOLO mode', async () => {
|
||||||
@@ -734,15 +762,15 @@ describe('E2E Tests', () => {
|
|||||||
expect(workingEvent2.kind).toBe('status-update');
|
expect(workingEvent2.kind).toBe('status-update');
|
||||||
expect(workingEvent2.status.state).toBe('working');
|
expect(workingEvent2.status.state).toBe('working');
|
||||||
|
|
||||||
// Status update: tool-call-update (validating)
|
// Status update: tool-call-update (scheduled)
|
||||||
const validatingEvent = events[3].result as TaskStatusUpdateEvent;
|
const scheduledEvent = events[3].result as TaskStatusUpdateEvent;
|
||||||
expect(validatingEvent.metadata?.['coderAgent']).toMatchObject({
|
expect(scheduledEvent.metadata?.['coderAgent']).toMatchObject({
|
||||||
kind: 'tool-call-update',
|
kind: 'tool-call-update',
|
||||||
});
|
});
|
||||||
expect(validatingEvent.status.message?.parts).toMatchObject([
|
expect(scheduledEvent.status.message?.parts).toMatchObject([
|
||||||
{
|
{
|
||||||
data: {
|
data: {
|
||||||
status: 'validating',
|
status: 'scheduled',
|
||||||
request: { callId: 'test-call-id-yolo' },
|
request: { callId: 'test-call-id-yolo' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -762,8 +790,22 @@ describe('E2E Tests', () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Status update: tool-call-update (scheduled)
|
||||||
|
const scheduledEvent3 = events[5].result as TaskStatusUpdateEvent;
|
||||||
|
expect(scheduledEvent3.metadata?.['coderAgent']).toMatchObject({
|
||||||
|
kind: 'tool-call-update',
|
||||||
|
});
|
||||||
|
expect(scheduledEvent3.status.message?.parts).toMatchObject([
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
status: 'scheduled',
|
||||||
|
request: { callId: 'test-call-id-yolo' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
// Status update: tool-call-update (executing)
|
// Status update: tool-call-update (executing)
|
||||||
const executingEvent = events[5].result as TaskStatusUpdateEvent;
|
const executingEvent = events[6].result as TaskStatusUpdateEvent;
|
||||||
expect(executingEvent.metadata?.['coderAgent']).toMatchObject({
|
expect(executingEvent.metadata?.['coderAgent']).toMatchObject({
|
||||||
kind: 'tool-call-update',
|
kind: 'tool-call-update',
|
||||||
});
|
});
|
||||||
@@ -777,7 +819,7 @@ describe('E2E Tests', () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// Status update: tool-call-update (success)
|
// Status update: tool-call-update (success)
|
||||||
const successEvent = events[6].result as TaskStatusUpdateEvent;
|
const successEvent = events[7].result as TaskStatusUpdateEvent;
|
||||||
expect(successEvent.metadata?.['coderAgent']).toMatchObject({
|
expect(successEvent.metadata?.['coderAgent']).toMatchObject({
|
||||||
kind: 'tool-call-update',
|
kind: 'tool-call-update',
|
||||||
});
|
});
|
||||||
@@ -791,12 +833,12 @@ describe('E2E Tests', () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// Status update: working (before sending tool result to LLM)
|
// Status update: working (before sending tool result to LLM)
|
||||||
const workingEvent3 = events[7].result as TaskStatusUpdateEvent;
|
const workingEvent3 = events[8].result as TaskStatusUpdateEvent;
|
||||||
expect(workingEvent3.kind).toBe('status-update');
|
expect(workingEvent3.kind).toBe('status-update');
|
||||||
expect(workingEvent3.status.state).toBe('working');
|
expect(workingEvent3.status.state).toBe('working');
|
||||||
|
|
||||||
// Status update: text-content (final LLM response)
|
// Status update: text-content (final LLM response)
|
||||||
const textContentEvent = events[8].result as TaskStatusUpdateEvent;
|
const textContentEvent = events[9].result as TaskStatusUpdateEvent;
|
||||||
expect(textContentEvent.metadata?.['coderAgent']).toMatchObject({
|
expect(textContentEvent.metadata?.['coderAgent']).toMatchObject({
|
||||||
kind: 'text-content',
|
kind: 'text-content',
|
||||||
});
|
});
|
||||||
@@ -805,7 +847,7 @@ describe('E2E Tests', () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
assertUniqueFinalEventIsLast(events);
|
assertUniqueFinalEventIsLast(events);
|
||||||
expect(events.length).toBe(10);
|
expect(events.length).toBe(11);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should include traceId in status updates when available', async () => {
|
it('should include traceId in status updates when available', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user