| |
| |
| |
| |
| |
|
|
| 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(), |
| }; |
|
|
| |
| |
| 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 () => { |
| |
| 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: [], |
| }); |
| |
| 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: [], |
| }); |
| |
| 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(), |
| }; |
|
|
| |
| 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(), |
| }; |
|
|
| |
| 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(), |
| }; |
|
|
| |
| 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, |
| ); |
|
|
| 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(), |
| }; |
|
|
| |
| 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'); |
|
|
| |
| const metadata = await task.getMetadata(); |
| expect(metadata.model).toBe('new-model-name'); |
|
|
| |
| 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(), |
| }; |
|
|
| |
| 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(), |
| }; |
|
|
| |
| const task = new Task( |
| 'task-id', |
| 'context-id', |
| mockConfig as Config, |
| mockEventBus, |
| ); |
|
|
| |
| expect(task.currentPromptId).toBeUndefined(); |
| expect(task.promptCount).toBe(0); |
|
|
| |
| const userMessage1 = { |
| userMessage: { |
| parts: [{ kind: 'text', text: 'hello' }], |
| }, |
| } as RequestContext; |
| const abortController1 = new AbortController(); |
| for await (const _ of task.acceptUserMessage( |
| userMessage1, |
| abortController1.signal, |
| )) { |
| |
| } |
|
|
| const expectedPromptId1 = 'test-session-id########0'; |
| expect(task.promptCount).toBe(1); |
| expect(task.currentPromptId).toBe(expectedPromptId1); |
|
|
| |
| const userMessage2 = { |
| userMessage: { |
| parts: [{ kind: 'text', text: 'world' }], |
| }, |
| } as RequestContext; |
| const abortController2 = new AbortController(); |
| for await (const _ of task.acceptUserMessage( |
| userMessage2, |
| abortController2.signal, |
| )) { |
| |
| } |
|
|
| const expectedPromptId2 = 'test-session-id########1'; |
| expect(task.promptCount).toBe(2); |
| expect(task.currentPromptId).toBe(expectedPromptId2); |
|
|
| |
| 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, |
| )) { |
| |
| } |
|
|
| 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 () => { |
| |
| const task = new Task( |
| 'task-id', |
| 'context-id', |
| mockConfig as Config, |
| mockEventBus, |
| ); |
|
|
| |
| task['_registerToolCall']('tool-1', 'awaiting_approval'); |
| task['_registerToolCall']('tool-2', 'validating'); |
|
|
| |
| task['checkInputRequiredState'](); |
|
|
| |
| 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 () => { |
| |
| const task = new Task( |
| 'task-id', |
| 'context-id', |
| mockConfig as Config, |
| mockEventBus, |
| ); |
|
|
| |
| task.taskState = 'working'; |
|
|
| |
| task['_registerToolCall']('tool-1', 'awaiting_approval'); |
|
|
| |
| task['checkInputRequiredState'](); |
|
|
| |
| 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 () => { |
| |
| 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(), |
| }; |
|
|
| |
| 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(), |
| }; |
|
|
| |
| 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(), |
| }; |
|
|
| |
| const task = new Task( |
| 'task-id', |
| 'context-id', |
| mockConfig as Config, |
| mockEventBus, |
| ); |
|
|
| const toolCall1 = { |
| request: { callId: 'tool-1' }, |
| status: 'awaiting_approval', |
| }; |
|
|
| |
| const changed1 = task['handleEventDrivenToolCall']( |
| toolCall1 as unknown as ToolCall, |
| ); |
| expect(changed1).toBe(true); |
|
|
| |
| const changed2 = task['handleEventDrivenToolCall']( |
| toolCall1 as unknown as ToolCall, |
| ); |
| expect(changed2).toBe(false); |
|
|
| |
| 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(), |
| }; |
|
|
| |
| 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'); |
| } 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(), |
| }; |
|
|
| |
| 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'); |
| } 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(), |
| }; |
|
|
| |
| const task = new Task( |
| 'task-id', |
| 'context-id', |
| mockConfig as Config, |
| mockEventBus, |
| ); |
|
|
| |
| task.cancelPendingTools('Execution aborted'); |
|
|
| |
| expect(task['cancellationError']).toBeDefined(); |
| await expect(task.waitForPendingTools()).rejects.toThrow( |
| 'Execution aborted', |
| ); |
|
|
| |
| const userMessage = { |
| userMessage: { |
| parts: [{ kind: 'text', text: 'new prompt' }], |
| }, |
| } as RequestContext; |
| const abortController = new AbortController(); |
|
|
| |
| for await (const _ of task.acceptUserMessage( |
| userMessage, |
| abortController.signal, |
| )) { |
| |
| } |
|
|
| |
| expect(task['cancellationError']).toBeUndefined(); |
|
|
| |
| await expect(task.waitForPendingTools()).resolves.toBeUndefined(); |
| }); |
| }); |
| }); |
|
|