// ***************************************************************************** // Copyright (C) 2025 EclipseSource GmbH. // // This program and the accompanying materials are made available under the // terms of the Eclipse Public License v. 2.0 which is available at // http://www.eclipse.org/legal/epl-2.0. // // This Source Code may also be made available under the following Secondary // Licenses when the conditions for such availability set forth in the Eclipse // Public License v. 2.0 are satisfied: GNU General Public License, version 2 // with the GNU Classpath Exception which is available at // https://www.gnu.org/software/classpath/license.html. // // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** import { expect } from 'chai'; import * as sinon from 'sinon'; import { ToolCallChatResponseContent, ToolCallChatResponseContentImpl } from './chat-model'; import { raceConfirmationWithTimeout } from './chat-tool-confirmation-timeout'; describe('ToolCallChatResponseContentImpl', () => { let consoleWarnStub: sinon.SinonStub; beforeEach(() => { consoleWarnStub = sinon.stub(console, 'warn'); }); afterEach(() => { consoleWarnStub.restore(); }); describe('toLanguageModelMessage', () => { it('should parse valid JSON arguments', () => { const toolCall = new ToolCallChatResponseContentImpl( 'test-id', 'test-tool', '{"key": "value", "number": 42}', true ); const [toolUseMessage] = toolCall.toLanguageModelMessage(); expect(toolUseMessage.input).to.deep.equal({ key: 'value', number: 42 }); expect(consoleWarnStub.called).to.be.false; }); it('should return empty object for malformed JSON and log warning', () => { const toolCall = new ToolCallChatResponseContentImpl( 'test-id', 'test-tool', '{"truncated": "json', true ); const [toolUseMessage] = toolCall.toLanguageModelMessage(); expect(toolUseMessage.input).to.deep.equal({}); expect(consoleWarnStub.calledOnce).to.be.true; expect(consoleWarnStub.firstCall.args[0]).to.include('Failed to parse tool call arguments'); expect(consoleWarnStub.firstCall.args[0]).to.include('test-tool'); }); it('should return empty object for empty arguments', () => { const toolCall = new ToolCallChatResponseContentImpl( 'test-id', 'test-tool', '', true ); const [toolUseMessage] = toolCall.toLanguageModelMessage(); expect(toolUseMessage.input).to.deep.equal({}); expect(consoleWarnStub.called).to.be.false; }); it('should return empty object for undefined arguments', () => { const toolCall = new ToolCallChatResponseContentImpl( 'test-id', 'test-tool', undefined, true ); const [toolUseMessage] = toolCall.toLanguageModelMessage(); expect(toolUseMessage.input).to.deep.equal({}); expect(consoleWarnStub.called).to.be.false; }); it('should handle mid-stream cancellation with truncated JSON', () => { // Simulates a cancelled stream that left truncated JSON const toolCall = new ToolCallChatResponseContentImpl( 'test-id', 'cancelledTool', '{"file": "/path/to/file.ts", "content": "partial content...', false // not finished ); const [toolUseMessage] = toolCall.toLanguageModelMessage(); expect(toolUseMessage.input).to.deep.equal({}); expect(consoleWarnStub.calledOnce).to.be.true; expect(consoleWarnStub.firstCall.args[0]).to.include('cancelledTool'); }); it('should parse complex nested JSON arguments', () => { const complexArgs = JSON.stringify({ nested: { deep: { value: 123 } }, array: [1, 2, 3], boolean: true }); const toolCall = new ToolCallChatResponseContentImpl( 'test-id', 'test-tool', complexArgs, true ); const [toolUseMessage] = toolCall.toLanguageModelMessage(); expect(toolUseMessage.input).to.deep.equal({ nested: { deep: { value: 123 } }, array: [1, 2, 3], boolean: true }); expect(consoleWarnStub.called).to.be.false; }); it('should include tool result in the result message', () => { const toolCall = new ToolCallChatResponseContentImpl( 'test-id', 'test-tool', '{}', true, 'Tool execution result' ); const [, toolResultMessage] = toolCall.toLanguageModelMessage(); expect(toolResultMessage.content).to.equal('Tool execution result'); expect(toolResultMessage.tool_use_id).to.equal('test-id'); }); }); describe('updateResult', () => { it('should set the result without marking the tool finished', () => { const toolCall = new ToolCallChatResponseContentImpl('id', 'tool', '{}', false); toolCall.updateResult('partial'); expect(toolCall.result).to.equal('partial'); expect(toolCall.finished).to.be.false; }); it('should fire onDidChange so auto-save picks up the partial result', () => { const toolCall = new ToolCallChatResponseContentImpl('id', 'tool', '{}', false); let fireCount = 0; toolCall.onDidChange(() => { fireCount++; }); toolCall.updateResult('partial-1'); toolCall.updateResult('partial-2'); expect(fireCount).to.equal(2); expect(toolCall.result).to.equal('partial-2'); }); }); describe('restored tool calls', () => { it('should have finished=true when restored with a result', () => { const restoredToolCall = new ToolCallChatResponseContentImpl( 'restored-id', 'shellExecute', '{"command": "ls -la"}', true, '{"success": true, "output": "file1.txt\\nfile2.txt"}' ); expect(restoredToolCall.finished).to.be.true; expect(restoredToolCall.result).to.exist; }); }); describe('whenFinished', () => { it('should resolve immediately when constructed with finished=true', async () => { const toolCall = new ToolCallChatResponseContentImpl( 'test-id', 'test-tool', '{}', true ); await toolCall.whenFinished; expect(toolCall.finished).to.be.true; }); it('should resolve when deny is called', async () => { const toolCall = new ToolCallChatResponseContentImpl( 'test-id', 'test-tool', '{}' ); expect(toolCall.finished).to.be.false; const finishedPromise = toolCall.whenFinished; toolCall.deny('test reason'); await finishedPromise; expect(toolCall.finished).to.be.true; }); it('should resolve when merged with finished content', async () => { const toolCall = new ToolCallChatResponseContentImpl( 'test-id', 'test-tool', '{}' ); expect(toolCall.finished).to.be.false; const finishedPromise = toolCall.whenFinished; const finishedContent = new ToolCallChatResponseContentImpl( 'test-id', 'test-tool', '{}', true, 'result' ); toolCall.merge(finishedContent); await finishedPromise; expect(toolCall.finished).to.be.true; }); it('should resolve when complete is called', async () => { const toolCall = new ToolCallChatResponseContentImpl( 'test-id', 'test-tool', '{}' ); expect(toolCall.finished).to.be.false; const finishedPromise = toolCall.whenFinished; toolCall.complete('execution result'); await finishedPromise; expect(toolCall.finished).to.be.true; expect(toolCall.result).to.equal('execution result'); }); }); }); describe('ToolCallChatResponseContent - confirmation state transitions', () => { function createToolCallContent(id = 'test-id', name = 'test-tool'): ToolCallChatResponseContentImpl { return new ToolCallChatResponseContentImpl(id, name, '{}'); } it('should resolve confirmed to true when confirm() is called', async () => { const content = createToolCallContent(); content.confirm(); const result = await content.confirmed; expect(result).to.be.true; }); it('should resolve confirmed to false when deny() is called with reason', async () => { const content = createToolCallContent(); content.deny('user rejected'); const result = await content.confirmed; expect(result).to.be.false; expect(content.result).to.deep.equal({ denied: true, reason: 'user rejected' }); }); it('should resolve confirmed to true after requestUserConfirmation() then confirm()', async () => { const content = createToolCallContent(); content.requestUserConfirmation(); content.confirm(); const result = await content.confirmed; expect(result).to.be.true; }); it('should resolve confirmed to false when deny() is called without reason', async () => { const content = createToolCallContent(); content.deny(); const result = await content.confirmed; expect(result).to.be.false; expect(content.result).to.deep.equal({ denied: true, reason: undefined }); }); describe('isAwaitingUserConfirmation', () => { it('should be false initially', () => { const content = createToolCallContent(); expect(content.isAwaitingUserConfirmation).to.be.false; }); it('should be true after requestUserConfirmation()', () => { const content = createToolCallContent(); content.requestUserConfirmation(); expect(content.isAwaitingUserConfirmation).to.be.true; }); it('should be false after the user confirms', () => { const content = createToolCallContent(); content.requestUserConfirmation(); content.confirm(); expect(content.isAwaitingUserConfirmation).to.be.false; }); it('should be false after the user denies', () => { const content = createToolCallContent(); content.requestUserConfirmation(); content.deny('rejected'); expect(content.isAwaitingUserConfirmation).to.be.false; }); it('should be false after the confirmation is canceled', () => { const content = createToolCallContent(); content.confirmed.catch(() => undefined); content.requestUserConfirmation(); content.cancelConfirmation(new Error('request canceled')); expect(content.isAwaitingUserConfirmation).to.be.false; }); it('should be false once the tool call is finished', () => { const content = createToolCallContent(); content.requestUserConfirmation(); content.complete('result'); expect(content.isAwaitingUserConfirmation).to.be.false; }); }); }); describe('Tool Confirmation Timeout', () => { let clock: sinon.SinonFakeTimers; beforeEach(() => { clock = sinon.useFakeTimers(); }); afterEach(() => { clock.restore(); }); function createToolCallContent(id = 'test-id', name = 'test-tool'): ToolCallChatResponseContentImpl { return new ToolCallChatResponseContentImpl(id, name, '{}'); } it('should auto-deny when timeout expires', async () => { const content = createToolCallContent(); const resultPromise = raceConfirmationWithTimeout(content, 30); await clock.tickAsync(30 * 1000); const result = await resultPromise; expect(result).to.be.false; expect(content.finished).to.be.true; expect(content.result).to.deep.equal({ denied: true, reason: 'Confirmation timed out after 30 seconds' }); }); it('should return true when user confirms before timeout', async () => { const content = createToolCallContent(); const resultPromise = raceConfirmationWithTimeout(content, 30); content.confirm(); await clock.tickAsync(0); const result = await resultPromise; expect(result).to.be.true; }); it('should return false when user denies before timeout with reason preserved', async () => { const content = createToolCallContent(); const resultPromise = raceConfirmationWithTimeout(content, 30); content.deny('not allowed'); await clock.tickAsync(0); const result = await resultPromise; expect(result).to.be.false; expect(content.result).to.deep.equal({ denied: true, reason: 'not allowed' }); }); it('should clean up timer when user confirms before timeout', async () => { const content = createToolCallContent(); const resultPromise = raceConfirmationWithTimeout(content, 30); content.confirm(); await clock.tickAsync(0); const result = await resultPromise; expect(result).to.be.true; // Tick past the timeout — should have no effect since the timer was cleared await clock.tickAsync(30 * 1000); // The content should not have been denied after confirm expect(content.result).to.be.undefined; }); it('should clean up timer when user denies before timeout', async () => { const content = createToolCallContent(); const resultPromise = raceConfirmationWithTimeout(content, 30); content.deny('user denied'); await clock.tickAsync(0); const result = await resultPromise; expect(result).to.be.false; expect(content.result).to.deep.equal({ denied: true, reason: 'user denied' }); // Tick past the timeout — timer should have been cleared await clock.tickAsync(30 * 1000); // Reason should still be the original user-provided reason, not the timeout message expect(content.result).to.deep.equal({ denied: true, reason: 'user denied' }); }); it('should not deny if already finished before timeout fires', async () => { const content = createToolCallContent(); // Start the timeout pattern (schedules the setTimeout) const resultPromise = raceConfirmationWithTimeout(content, 30); // Tool completes before the timeout (marks finished=true but does NOT resolve confirmed) content.complete('some result'); expect(content.finished).to.be.true; // Tick past the timeout - the callback fires, checks the guard, and skips deny await clock.tickAsync(30 * 1000); const result = await resultPromise; // The timeoutPromise resolved with false (timeout expired) expect(result).to.be.false; // The result should still be the original completion result, not a denial expect(content.result).to.equal('some result'); expect(ToolCallChatResponseContent.isDenialResult(content.result)).to.be.false; }); it('should await confirmed directly when timeout is 0', async () => { const content = createToolCallContent(); const resultPromise = raceConfirmationWithTimeout(content, 0); content.confirm(); await clock.tickAsync(0); const result = await resultPromise; expect(result).to.be.true; }); });