Download packages/a2a-server/src/agent/task.test.ts from SaylorTwift/gemini-cli: direct link, hf CLI and curl.
- Browser
- Download file 27 kB
-
https://huggingface.co/SaylorTwift/gemini-cli/resolve/main/packages/a2a-server/src/agent/task.test.ts
- Command line
-
hf download hf://SaylorTwift/gemini-cli/packages/a2a-server/src/agent/task.test.ts
-
curl -L -o task.test.ts https://huggingface.co/SaylorTwift/gemini-cli/resolve/main/packages/a2a-server/src/agent/task.test.ts
27 kB
| /** | |
| * @license | |
| * Copyright 2025 Google LLC | |
| * SPDX-License-Identifier: Apache-2.0 | |
| */ | |
| import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; | |
| import { Task } from './task.js'; | |
| import { | |
| GeminiEventType, | |
| type Config, | |
| type ToolCallRequestInfo, | |
| type GitService, | |
| type CompletedToolCall, | |
| type ToolCall, | |
| type ToolCallsUpdateMessage, | |
| MessageBusType, | |
| } from '@google/gemini-cli-core'; | |
| import { createMockConfig } from '../utils/testing_utils.js'; | |
| import type { ExecutionEventBus, RequestContext } from '@a2a-js/sdk/server'; | |
| import { CoderAgentEvent } from '../types.js'; | |
| const mockProcessRestorableToolCalls = vi.hoisted(() => vi.fn()); | |
| vi.mock('@google/gemini-cli-core', async (importOriginal) => { | |
| const original = | |
| await importOriginal<typeof import('@google/gemini-cli-core')>(); | |
| return { | |
| ...original, | |
| processRestorableToolCalls: mockProcessRestorableToolCalls, | |
| }; | |
| }); | |
| describe('Task', () => { | |
| it('scheduleToolCalls should not modify the input requests array', 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(), | |
| }; | |
| // The Task constructor is private. We'll bypass it for this unit test. | |
| // @ts-expect-error - Calling private constructor for test purposes. | |
| const task = new Task( | |
| 'task-id', | |
| 'context-id', | |
| mockConfig as Config, | |
| mockEventBus, | |
| ); | |
| task['setTaskStateAndPublishUpdate'] = vi.fn(); | |
| task['getProposedContent'] = vi.fn().mockResolvedValue('new content'); | |
| const requests: ToolCallRequestInfo[] = [ | |
| { | |
| callId: '1', | |
| name: 'replace', | |
| args: { | |
| file_path: 'test.txt', | |
| old_string: 'old', | |
| new_string: 'new', | |
| }, | |
| isClientInitiated: false, | |
| prompt_id: 'prompt-id-1', | |
| }, | |
| ]; | |
| const originalRequests = JSON.parse(JSON.stringify(requests)); | |
| const abortController = new AbortController(); | |
| await task.scheduleToolCalls(requests, abortController.signal); | |
| expect(requests).toEqual(originalRequests); | |
| }); | |
| describe('scheduleToolCalls', () => { | |
| 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 create a checkpoint if no restorable tools are called', async () => { | |
| // @ts-expect-error - Calling private constructor for test purposes. | |
| const task = new Task( | |
| 'task-id', | |
| 'context-id', | |
| mockConfig as Config, | |
| mockEventBus, | |
| ); | |
| const requests: ToolCallRequestInfo[] = [ | |
| { | |
| callId: '1', | |
| name: 'run_shell_command', | |
| args: { command: 'ls' }, | |
| isClientInitiated: false, | |
| prompt_id: 'prompt-id-1', | |
| }, | |
| ]; | |
| const abortController = new AbortController(); | |
| await task.scheduleToolCalls(requests, abortController.signal); | |
| expect(mockProcessRestorableToolCalls).not.toHaveBeenCalled(); | |
| }); | |
| it('should create a checkpoint if a restorable tool is called', async () => { | |
| const mockConfig = createMockConfig({ | |
| getCheckpointingEnabled: () => true, | |
| getGitService: () => Promise.resolve({} as GitService), | |
| }); | |
| mockProcessRestorableToolCalls.mockResolvedValue({ | |
| checkpointsToWrite: new Map([['test.json', 'test content']]), | |
| toolCallToCheckpointMap: new Map(), | |
| errors: [], | |
| }); | |
| // @ts-expect-error - Calling private constructor for test purposes. | |
| const task = new Task( | |
| 'task-id', | |
| 'context-id', | |
| mockConfig as Config, | |
| mockEventBus, | |
| ); | |
| const requests: ToolCallRequestInfo[] = [ | |
| { | |
| callId: '1', | |
| name: 'replace', | |
| args: { | |
| file_path: 'test.txt', | |
| old_string: 'old', | |
| new_string: 'new', | |
| }, | |
| isClientInitiated: false, | |
| prompt_id: 'prompt-id-1', | |
| }, | |
| ]; | |
| const abortController = new AbortController(); | |
| await task.scheduleToolCalls(requests, abortController.signal); | |
| expect(mockProcessRestorableToolCalls).toHaveBeenCalledOnce(); | |
| }); | |
| it('should process all restorable tools for checkpointing in a single batch', async () => { | |
| const mockConfig = createMockConfig({ | |
| getCheckpointingEnabled: () => true, | |
| getGitService: () => Promise.resolve({} as GitService), | |
| }); | |
| mockProcessRestorableToolCalls.mockResolvedValue({ | |
| checkpointsToWrite: new Map([ | |
| ['test1.json', 'test content 1'], | |
| ['test2.json', 'test content 2'], | |
| ]), | |
| toolCallToCheckpointMap: new Map([ | |
| ['1', 'test1'], | |
| ['2', 'test2'], | |
| ]), | |
| errors: [], | |
| }); | |
| // @ts-expect-error - Calling private constructor for test purposes. | |
| const task = new Task( | |
| 'task-id', | |
| 'context-id', | |
| mockConfig as Config, | |
| mockEventBus, | |
| ); | |
| const requests: ToolCallRequestInfo[] = [ | |
| { | |
| callId: '1', | |
| name: 'replace', | |
| args: { | |
| file_path: 'test.txt', | |
| old_string: 'old', | |
| new_string: 'new', | |
| }, | |
| isClientInitiated: false, | |
| prompt_id: 'prompt-id-1', | |
| }, | |
| { | |
| callId: '2', | |
| name: 'write_file', | |
| args: { file_path: 'test2.txt', content: 'new content' }, | |
| isClientInitiated: false, | |
| prompt_id: 'prompt-id-2', | |
| }, | |
| { | |
| callId: '3', | |
| name: 'not_restorable', | |
| args: {}, | |
| isClientInitiated: false, | |
| prompt_id: 'prompt-id-3', | |
| }, | |
| ]; | |
| const abortController = new AbortController(); | |
| await task.scheduleToolCalls(requests, abortController.signal); | |
| expect(mockProcessRestorableToolCalls).toHaveBeenCalledExactlyOnceWith( | |
| [ | |
| expect.objectContaining({ callId: '1' }), | |
| expect.objectContaining({ callId: '2' }), | |
| ], | |
| expect.anything(), | |
| expect.anything(), | |
| ); | |
| }); | |
| }); | |
| describe('acceptAgentMessage', () => { | |
| it('should set currentTraceId when event has traceId', 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 for test purposes. | |
| const task = new Task( | |
| 'task-id', | |
| 'context-id', | |
| mockConfig as Config, | |
| mockEventBus, | |
| ); | |
| const event = { | |
| type: 'content', | |
| value: 'test', | |
| traceId: 'test-trace-id', | |
| }; | |
| await task.acceptAgentMessage(event); | |
| expect(mockEventBus.publish).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| metadata: expect.objectContaining({ | |
| traceId: 'test-trace-id', | |
| }), | |
| }), | |
| ); | |
| }); | |
| it('should handle Citation event and publish to event bus', 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 for test purposes. | |
| const task = new Task( | |
| 'task-id', | |
| 'context-id', | |
| mockConfig as Config, | |
| mockEventBus, | |
| ); | |
| const citationText = 'Source: example.com'; | |
| const citationEvent = { | |
| type: GeminiEventType.Citation, | |
| value: citationText, | |
| }; | |
| await task.acceptAgentMessage(citationEvent); | |
| expect(mockEventBus.publish).toHaveBeenCalledOnce(); | |
| const publishedEvent = (mockEventBus.publish as Mock).mock.calls[0][0]; | |
| expect(publishedEvent.kind).toBe('status-update'); | |
| expect(publishedEvent.taskId).toBe('task-id'); | |
| expect(publishedEvent.metadata.coderAgent.kind).toBe( | |
| CoderAgentEvent.CitationEvent, | |
| ); | |
| expect(publishedEvent.status.message).toBeDefined(); | |
| expect(publishedEvent.status.message.parts).toEqual([ | |
| { | |
| kind: 'text', | |
| text: citationText, | |
| }, | |
| ]); | |
| }); | |
| it('should capture usageMetadata on Finished event and include it in final status update', 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 for test purposes. | |
| const task = new Task( | |
| 'task-id', | |
| 'context-id', | |
| mockConfig as Config, | |
| mockEventBus, | |
| ); | |
| const finishedEvent = { | |
| type: GeminiEventType.Finished, | |
| value: { | |
| reason: 'STOP', | |
| usageMetadata: { | |
| promptTokenCount: 100, | |
| candidatesTokenCount: 50, | |
| totalTokenCount: 150, | |
| }, | |
| }, | |
| }; | |
| await task.acceptAgentMessage(finishedEvent); | |
| expect(task.usageMetadata).toEqual({ | |
| promptTokenCount: 100, | |
| candidatesTokenCount: 50, | |
| totalTokenCount: 150, | |
| }); | |
| task.setTaskStateAndPublishUpdate( | |
| 'input-required', | |
| { kind: CoderAgentEvent.StateChangeEvent }, | |
| undefined, | |
| undefined, | |
| true, // final | |
| ); | |
| expect(mockEventBus.publish).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| final: true, | |
| metadata: expect.objectContaining({ | |
| usageMetadata: { | |
| promptTokenCount: 100, | |
| candidatesTokenCount: 50, | |
| totalTokenCount: 150, | |
| }, | |
| }), | |
| }), | |
| ); | |
| }); | |
| it('should update modelInfo and reflect it in metadata and status updates', 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 for test purposes. | |
| const task = new Task( | |
| 'task-id', | |
| 'context-id', | |
| mockConfig as Config, | |
| mockEventBus, | |
| ); | |
| const modelInfoEvent = { | |
| type: GeminiEventType.ModelInfo, | |
| value: 'new-model-name', | |
| }; | |
| await task.acceptAgentMessage(modelInfoEvent); | |
| expect(task.modelInfo).toBe('new-model-name'); | |
| // Check getMetadata | |
| const metadata = await task.getMetadata(); | |
| expect(metadata.model).toBe('new-model-name'); | |
| // Check status update | |
| task.setTaskStateAndPublishUpdate( | |
| 'working', | |
| { kind: CoderAgentEvent.StateChangeEvent }, | |
| 'Working...', | |
| ); | |
| expect(mockEventBus.publish).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| metadata: expect.objectContaining({ | |
| model: 'new-model-name', | |
| }), | |
| }), | |
| ); | |
| }); | |
| it.each([ | |
| { eventType: GeminiEventType.Retry, eventName: 'Retry' }, | |
| { eventType: GeminiEventType.InvalidStream, eventName: 'InvalidStream' }, | |
| ])( | |
| 'should handle $eventName event without triggering error handling', | |
| async ({ eventType }) => { | |
| 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 cancelPendingToolsSpy = vi.spyOn(task, 'cancelPendingTools'); | |
| const setTaskStateSpy = vi.spyOn(task, 'setTaskStateAndPublishUpdate'); | |
| const event = { | |
| type: eventType, | |
| }; | |
| await task.acceptAgentMessage(event); | |
| expect(cancelPendingToolsSpy).not.toHaveBeenCalled(); | |
| expect(setTaskStateSpy).not.toHaveBeenCalled(); | |
| }, | |
| ); | |
| }); | |
| describe('currentPromptId and promptCount', () => { | |
| it('should correctly initialize and update promptId and promptCount', async () => { | |
| const mockConfig = createMockConfig(); | |
| mockConfig.getGeminiClient = vi.fn().mockReturnValue({ | |
| sendMessageStream: vi.fn().mockReturnValue((async function* () {})()), | |
| }); | |
| mockConfig.getSessionId = () => 'test-session-id'; | |
| 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, | |
| ); | |
| // Initial state | |
| expect(task.currentPromptId).toBeUndefined(); | |
| expect(task.promptCount).toBe(0); | |
| // First user message should set prompt_id | |
| const userMessage1 = { | |
| userMessage: { | |
| parts: [{ kind: 'text', text: 'hello' }], | |
| }, | |
| } as RequestContext; | |
| const abortController1 = new AbortController(); | |
| for await (const _ of task.acceptUserMessage( | |
| userMessage1, | |
| abortController1.signal, | |
| )) { | |
| // no-op | |
| } | |
| const expectedPromptId1 = 'test-session-id########0'; | |
| expect(task.promptCount).toBe(1); | |
| expect(task.currentPromptId).toBe(expectedPromptId1); | |
| // A new user message should generate a new prompt_id | |
| const userMessage2 = { | |
| userMessage: { | |
| parts: [{ kind: 'text', text: 'world' }], | |
| }, | |
| } as RequestContext; | |
| const abortController2 = new AbortController(); | |
| for await (const _ of task.acceptUserMessage( | |
| userMessage2, | |
| abortController2.signal, | |
| )) { | |
| // no-op | |
| } | |
| const expectedPromptId2 = 'test-session-id########1'; | |
| expect(task.promptCount).toBe(2); | |
| expect(task.currentPromptId).toBe(expectedPromptId2); | |
| // Subsequent tool call processing should use the same prompt_id | |
| const completedTool = { | |
| request: { callId: 'tool-1' }, | |
| response: { responseParts: [{ text: 'tool output' }] }, | |
| } as CompletedToolCall; | |
| const abortController3 = new AbortController(); | |
| for await (const _ of task.sendCompletedToolsToLlm( | |
| [completedTool], | |
| abortController3.signal, | |
| )) { | |
| // no-op | |
| } | |
| expect(task.promptCount).toBe(2); | |
| 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('Pending Tools state', () => { | |
| it('should correctly report pending tools presence and count', () => { | |
| 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, | |
| ); | |
| expect(task.hasPendingTools).toBe(false); | |
| expect(task.pendingToolsCount).toBe(0); | |
| task['_registerToolCall']('tool-1', 'scheduled'); | |
| expect(task.hasPendingTools).toBe(true); | |
| expect(task.pendingToolsCount).toBe(1); | |
| }); | |
| }); | |
| }); | |
| 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); | |
| }); | |
| }); | |
| describe('getProposedContent (CRLF Line Ending Normalization)', () => { | |
| it('should successfully replace LF-based strings in CRLF-based files', async () => { | |
| const fs = await import('node:fs'); | |
| const path = await import('node:path'); | |
| const os = await import('node:os'); | |
| const mockConfig = createMockConfig({ | |
| getTargetDir: () => os.tmpdir(), | |
| validatePathAccess: () => null, | |
| }); | |
| 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 tempFile = path.resolve(os.tmpdir(), 'crlf_test_file.txt'); | |
| const crlfContent = 'line1\r\nline2\r\nline3\r\n'; | |
| fs.writeFileSync(tempFile, crlfContent, 'utf8'); | |
| try { | |
| const oldString = 'line2\n'; | |
| const newString = 'line2-optimized\n'; | |
| const result = await task['getProposedContent']( | |
| tempFile, | |
| oldString, | |
| newString, | |
| ); | |
| expect(result).toContain('line2-optimized'); | |
| expect(result).toContain('\r\n'); // It should preserve the original CRLF line endings | |
| } finally { | |
| if (fs.existsSync(tempFile)) { | |
| fs.unlinkSync(tempFile); | |
| } | |
| } | |
| }); | |
| it('should successfully replace CRLF-based strings in CRLF-based files by normalizing all to LF', async () => { | |
| const fs = await import('node:fs'); | |
| const path = await import('node:path'); | |
| const os = await import('node:os'); | |
| const mockConfig = createMockConfig({ | |
| getTargetDir: () => os.tmpdir(), | |
| validatePathAccess: () => null, | |
| }); | |
| 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 tempFile = path.resolve( | |
| os.tmpdir(), | |
| 'crlf_test_file_crlf_inputs.txt', | |
| ); | |
| const crlfContent = 'line1\r\nline2\r\nline3\r\n'; | |
| fs.writeFileSync(tempFile, crlfContent, 'utf8'); | |
| try { | |
| const oldString = 'line2\r\n'; | |
| const newString = 'line2-optimized\r\n'; | |
| const result = await task['getProposedContent']( | |
| tempFile, | |
| oldString, | |
| newString, | |
| ); | |
| expect(result).toContain('line2-optimized'); | |
| expect(result).toContain('\r\n'); // It should preserve the original CRLF line endings | |
| } finally { | |
| if (fs.existsSync(tempFile)) { | |
| fs.unlinkSync(tempFile); | |
| } | |
| } | |
| }); | |
| }); | |
| describe('Stale Cancellation Error Cleanup', () => { | |
| it('should clear any previous execution cancellation error on acceptUserMessage', async () => { | |
| const mockConfig = createMockConfig(); | |
| mockConfig.getGeminiClient = vi.fn().mockReturnValue({ | |
| sendMessageStream: vi.fn().mockReturnValue((async function* () {})()), | |
| }); | |
| mockConfig.getSessionId = () => 'test-session-id'; | |
| 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, | |
| ); | |
| // Simulate cancellation from a previous aborted execution turn | |
| task.cancelPendingTools('Execution aborted'); | |
| // Verify cancellationError is set and would throw on wait | |
| expect(task['cancellationError']).toBeDefined(); | |
| await expect(task.waitForPendingTools()).rejects.toThrow( | |
| 'Execution aborted', | |
| ); | |
| // Call acceptUserMessage to start a new user turn | |
| const userMessage = { | |
| userMessage: { | |
| parts: [{ kind: 'text', text: 'new prompt' }], | |
| }, | |
| } as RequestContext; | |
| const abortController = new AbortController(); | |
| // Drain the generator to let acceptUserMessage execute | |
| for await (const _ of task.acceptUserMessage( | |
| userMessage, | |
| abortController.signal, | |
| )) { | |
| // no-op | |
| } | |
| // Verify cancellationError is cleared | |
| expect(task['cancellationError']).toBeUndefined(); | |
| // Verify waiting for pending tools now succeeds/does not throw | |
| await expect(task.waitForPendingTools()).resolves.toBeUndefined(); | |
| }); | |
| }); | |
| }); | |