deploy: current vibn theia state
Made-with: Cursor
This commit is contained in:
672
packages/ai-codex/src/browser/codex-chat-agent.spec.ts
Normal file
672
packages/ai-codex/src/browser/codex-chat-agent.spec.ts
Normal file
@@ -0,0 +1,672 @@
|
||||
// *****************************************************************************
|
||||
// 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 { FrontendApplicationConfigProvider } from '@theia/core/lib/browser/frontend-application-config-provider';
|
||||
import { enableJSDOM } from '@theia/core/lib/browser/test/jsdom';
|
||||
let disableJSDOM = enableJSDOM();
|
||||
FrontendApplicationConfigProvider.set({});
|
||||
|
||||
import { expect } from 'chai';
|
||||
import * as sinon from 'sinon';
|
||||
import { Container } from '@theia/core/shared/inversify';
|
||||
import { TokenUsageService } from '@theia/ai-core';
|
||||
import { FileService } from '@theia/filesystem/lib/browser/file-service';
|
||||
import { WorkspaceService } from '@theia/workspace/lib/browser';
|
||||
import { URI } from '@theia/core/lib/common/uri';
|
||||
import { ChangeSetFileElementFactory } from '@theia/ai-chat/lib/browser/change-set-file-element';
|
||||
import { ChatAgentLocation, MarkdownChatResponseContentImpl, ThinkingChatResponseContentImpl, ErrorChatResponseContentImpl, MutableChatRequestModel } from '@theia/ai-chat';
|
||||
import { CodexFrontendService } from './codex-frontend-service';
|
||||
import { CodexChatAgent, CODEX_CHAT_AGENT_ID, CODEX_TOOL_CALLS_KEY, CODEX_INPUT_TOKENS_KEY, CODEX_OUTPUT_TOKENS_KEY } from './codex-chat-agent';
|
||||
|
||||
import type {
|
||||
CommandExecutionItem,
|
||||
FileChangeItem,
|
||||
McpToolCallItem,
|
||||
WebSearchItem,
|
||||
TodoListItem,
|
||||
AgentMessageItem,
|
||||
ItemCompletedEvent,
|
||||
TurnCompletedEvent,
|
||||
ThreadEvent
|
||||
} from '@openai/codex-sdk';
|
||||
|
||||
disableJSDOM();
|
||||
|
||||
/**
|
||||
* Helper interface to access protected methods for testing purposes.
|
||||
* This avoids using 'as any' casts when testing protected methods.
|
||||
*/
|
||||
interface CodexChatAgentTestAccess {
|
||||
getToolCalls(request: MutableChatRequestModel): Map<string, unknown>;
|
||||
isToolInvocation(item: unknown): boolean;
|
||||
extractToolArguments(item: CommandExecutionItem | FileChangeItem | McpToolCallItem | WebSearchItem | TodoListItem): string;
|
||||
extractSandboxMode(modeId?: string): 'read-only' | 'workspace-write' | 'danger-full-access';
|
||||
updateTokens(request: MutableChatRequestModel, inputTokens: number, outputTokens: number): void;
|
||||
getSessionTotalTokens(request: MutableChatRequestModel): { inputTokens: number; outputTokens: number };
|
||||
}
|
||||
|
||||
describe('CodexChatAgent', () => {
|
||||
let container: Container;
|
||||
let mockRequest: MutableChatRequestModel;
|
||||
|
||||
before(async () => {
|
||||
disableJSDOM = enableJSDOM();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
container = new Container();
|
||||
|
||||
const mockCodexService = {
|
||||
send: sinon.stub()
|
||||
} as unknown as CodexFrontendService;
|
||||
|
||||
const mockTokenUsageService = {
|
||||
recordTokenUsage: sinon.stub().resolves(),
|
||||
getTokenUsages: sinon.stub().resolves([]),
|
||||
setClient: sinon.stub()
|
||||
};
|
||||
|
||||
const mockFileService = {
|
||||
exists: sinon.stub().resolves(true),
|
||||
read: sinon.stub().resolves({ value: { toString: () => 'content' } })
|
||||
} as unknown as FileService;
|
||||
|
||||
const mockWorkspaceService = {
|
||||
roots: Promise.resolve([{ resource: new URI('file:///test') }])
|
||||
} as unknown as WorkspaceService;
|
||||
|
||||
const mockFileChangeFactory = sinon.stub();
|
||||
|
||||
container.bind(CodexFrontendService).toConstantValue(mockCodexService);
|
||||
container.bind(TokenUsageService).toConstantValue(mockTokenUsageService);
|
||||
container.bind(FileService).toConstantValue(mockFileService);
|
||||
container.bind(WorkspaceService).toConstantValue(mockWorkspaceService);
|
||||
container.bind(ChangeSetFileElementFactory).toConstantValue(mockFileChangeFactory);
|
||||
container.bind(CodexChatAgent).toSelf();
|
||||
|
||||
const addContentStub = sinon.stub();
|
||||
const responseContentChangedStub = sinon.stub();
|
||||
const completeStub = sinon.stub();
|
||||
const errorStub = sinon.stub();
|
||||
const getRequestsStub = sinon.stub().returns([]);
|
||||
const setSuggestionsStub = sinon.stub();
|
||||
const addDataStub = sinon.stub();
|
||||
const getDataByKeyStub = sinon.stub();
|
||||
|
||||
mockRequest = {
|
||||
id: 'test-request-id',
|
||||
request: { text: 'test prompt' },
|
||||
session: {
|
||||
id: 'test-session-id',
|
||||
getRequests: getRequestsStub,
|
||||
setSuggestions: setSuggestionsStub
|
||||
},
|
||||
response: {
|
||||
response: {
|
||||
addContent: addContentStub,
|
||||
responseContentChanged: responseContentChangedStub
|
||||
},
|
||||
complete: completeStub,
|
||||
error: errorStub,
|
||||
cancellationToken: { isCancellationRequested: false }
|
||||
},
|
||||
addData: addDataStub,
|
||||
getDataByKey: getDataByKeyStub
|
||||
} as unknown as MutableChatRequestModel;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
sinon.restore();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
disableJSDOM();
|
||||
});
|
||||
|
||||
function createAgentMessageEvent(text: string, id: string = 'msg-1'): ItemCompletedEvent {
|
||||
return {
|
||||
type: 'item.completed',
|
||||
item: {
|
||||
type: 'agent_message',
|
||||
id,
|
||||
text
|
||||
} as AgentMessageItem
|
||||
};
|
||||
}
|
||||
|
||||
function createReasoningEvent(text: string, id: string = 'reason-1'): ItemCompletedEvent {
|
||||
return {
|
||||
type: 'item.completed',
|
||||
item: {
|
||||
type: 'reasoning',
|
||||
id,
|
||||
text
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createCommandExecutionCompletedEvent(command: string, exitCode: number, output: string, id: string = 'cmd-1'): ItemCompletedEvent {
|
||||
return {
|
||||
type: 'item.completed',
|
||||
item: {
|
||||
type: 'command_execution',
|
||||
id,
|
||||
command,
|
||||
status: 'completed' as const,
|
||||
exit_code: exitCode,
|
||||
aggregated_output: output
|
||||
} as unknown as CommandExecutionItem
|
||||
};
|
||||
}
|
||||
|
||||
function createTurnCompletedEvent(inputTokens: number, outputTokens: number): TurnCompletedEvent {
|
||||
return {
|
||||
type: 'turn.completed',
|
||||
usage: {
|
||||
input_tokens: inputTokens,
|
||||
output_tokens: outputTokens,
|
||||
cached_input_tokens: 0
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function* createMockStream(events: ThreadEvent[]): AsyncIterable<ThreadEvent> {
|
||||
for (const event of events) {
|
||||
yield event;
|
||||
}
|
||||
}
|
||||
|
||||
describe('agent metadata', () => {
|
||||
it('should have correct id', () => {
|
||||
const agent = container.get<CodexChatAgent>(CodexChatAgent);
|
||||
expect(agent.id).to.equal(CODEX_CHAT_AGENT_ID);
|
||||
});
|
||||
|
||||
it('should have correct name', () => {
|
||||
const agent = container.get<CodexChatAgent>(CodexChatAgent);
|
||||
expect(agent.name).to.equal('Codex');
|
||||
});
|
||||
|
||||
it('should have correct description', () => {
|
||||
const agent = container.get<CodexChatAgent>(CodexChatAgent);
|
||||
expect(agent.description).to.include('OpenAI');
|
||||
expect(agent.description).to.include('Codex');
|
||||
});
|
||||
|
||||
it('should support all chat locations', () => {
|
||||
const agent = container.get<CodexChatAgent>(CodexChatAgent);
|
||||
expect(agent.locations).to.deep.equal(ChatAgentLocation.ALL);
|
||||
});
|
||||
|
||||
it('should have three modes', () => {
|
||||
const agent = container.get<CodexChatAgent>(CodexChatAgent);
|
||||
expect(agent.modes).to.have.lengthOf(3);
|
||||
expect(agent.modes![0].id).to.equal('workspace-write');
|
||||
expect(agent.modes![1].id).to.equal('read-only');
|
||||
expect(agent.modes![2].id).to.equal('danger-full-access');
|
||||
});
|
||||
});
|
||||
|
||||
describe('invoke() integration tests', () => {
|
||||
it('should process agent_message events through invoke', async () => {
|
||||
const agent = container.get<CodexChatAgent>(CodexChatAgent);
|
||||
const mockCodexService = container.get<CodexFrontendService>(CodexFrontendService);
|
||||
|
||||
const events = [
|
||||
createAgentMessageEvent('Hello, I can help you with that.'),
|
||||
createTurnCompletedEvent(50, 25)
|
||||
];
|
||||
(mockCodexService.send as sinon.SinonStub).resolves(createMockStream(events));
|
||||
|
||||
await agent.invoke(mockRequest);
|
||||
|
||||
const addContentStub = (mockRequest.response.response.addContent as sinon.SinonStub);
|
||||
const completeStub = (mockRequest.response.complete as sinon.SinonStub);
|
||||
expect(addContentStub.calledOnce).to.be.true;
|
||||
const addedContent = addContentStub.firstCall.args[0];
|
||||
expect(addedContent).to.be.instanceOf(MarkdownChatResponseContentImpl);
|
||||
expect(addedContent.content.value).to.equal('Hello, I can help you with that.');
|
||||
expect(completeStub.calledOnce).to.be.true;
|
||||
});
|
||||
|
||||
it('should process reasoning events through invoke', async () => {
|
||||
const agent = container.get<CodexChatAgent>(CodexChatAgent);
|
||||
const mockCodexService = container.get<CodexFrontendService>(CodexFrontendService);
|
||||
|
||||
const events = [
|
||||
createReasoningEvent('Let me think about the best approach...'),
|
||||
createTurnCompletedEvent(30, 15)
|
||||
];
|
||||
(mockCodexService.send as sinon.SinonStub).resolves(createMockStream(events));
|
||||
|
||||
await agent.invoke(mockRequest);
|
||||
|
||||
const addContentStub = (mockRequest.response.response.addContent as sinon.SinonStub);
|
||||
const completeStub = (mockRequest.response.complete as sinon.SinonStub);
|
||||
expect(addContentStub.calledOnce).to.be.true;
|
||||
const addedContent = addContentStub.firstCall.args[0];
|
||||
expect(addedContent).to.be.instanceOf(ThinkingChatResponseContentImpl);
|
||||
expect(addedContent.content).to.equal('Let me think about the best approach...');
|
||||
expect(completeStub.calledOnce).to.be.true;
|
||||
});
|
||||
|
||||
it('should process command_execution tool calls through invoke', async () => {
|
||||
const agent = container.get<CodexChatAgent>(CodexChatAgent);
|
||||
const mockCodexService = container.get<CodexFrontendService>(CodexFrontendService);
|
||||
|
||||
(mockRequest.getDataByKey as sinon.SinonStub).withArgs(CODEX_TOOL_CALLS_KEY).returns(undefined);
|
||||
|
||||
const events = [
|
||||
{
|
||||
type: 'item.started' as const,
|
||||
item: {
|
||||
type: 'command_execution' as const,
|
||||
id: 'cmd-1',
|
||||
command: 'npm test',
|
||||
status: 'running' as const,
|
||||
aggregated_output: ''
|
||||
} as unknown as CommandExecutionItem
|
||||
},
|
||||
createCommandExecutionCompletedEvent('npm test', 0, 'All tests passed'),
|
||||
createTurnCompletedEvent(100, 50)
|
||||
];
|
||||
(mockCodexService.send as sinon.SinonStub).resolves(createMockStream(events));
|
||||
|
||||
await agent.invoke(mockRequest);
|
||||
|
||||
// Should add tool call twice: once on start (pending), once on completion
|
||||
const addContentStub = (mockRequest.response.response.addContent as sinon.SinonStub);
|
||||
const completeStub = (mockRequest.response.complete as sinon.SinonStub);
|
||||
expect(addContentStub.callCount).to.equal(2);
|
||||
expect(completeStub.calledOnce).to.be.true;
|
||||
});
|
||||
|
||||
it('should process turn.completed events through invoke', async () => {
|
||||
const agent = container.get<CodexChatAgent>(CodexChatAgent);
|
||||
const mockCodexService = container.get<CodexFrontendService>(CodexFrontendService);
|
||||
|
||||
const events = [
|
||||
createAgentMessageEvent('Done!'),
|
||||
createTurnCompletedEvent(150, 75)
|
||||
];
|
||||
(mockCodexService.send as sinon.SinonStub).resolves(createMockStream(events));
|
||||
(mockRequest.session.getRequests as sinon.SinonStub).returns([mockRequest]);
|
||||
(mockRequest.getDataByKey as sinon.SinonStub).withArgs(CODEX_INPUT_TOKENS_KEY).returns(0);
|
||||
(mockRequest.getDataByKey as sinon.SinonStub).withArgs(CODEX_OUTPUT_TOKENS_KEY).returns(0);
|
||||
|
||||
await agent.invoke(mockRequest);
|
||||
|
||||
const addDataStub = (mockRequest.addData as sinon.SinonStub);
|
||||
const completeStub = (mockRequest.response.complete as sinon.SinonStub);
|
||||
expect(addDataStub.calledWith(CODEX_INPUT_TOKENS_KEY, 150)).to.be.true;
|
||||
expect(addDataStub.calledWith(CODEX_OUTPUT_TOKENS_KEY, 75)).to.be.true;
|
||||
expect(completeStub.calledOnce).to.be.true;
|
||||
});
|
||||
|
||||
it('should handle errors from codexService.send', async () => {
|
||||
const agent = container.get<CodexChatAgent>(CodexChatAgent);
|
||||
const mockCodexService = container.get<CodexFrontendService>(CodexFrontendService);
|
||||
|
||||
const testError = new Error('Network failure');
|
||||
(mockCodexService.send as sinon.SinonStub).rejects(testError);
|
||||
|
||||
await agent.invoke(mockRequest);
|
||||
|
||||
const addContentStub = (mockRequest.response.response.addContent as sinon.SinonStub);
|
||||
const errorStub = (mockRequest.response.error as sinon.SinonStub);
|
||||
expect(addContentStub.calledOnce).to.be.true;
|
||||
const addedContent = addContentStub.firstCall.args[0];
|
||||
expect(addedContent).to.be.instanceOf(ErrorChatResponseContentImpl);
|
||||
expect(errorStub.calledWith(testError)).to.be.true;
|
||||
});
|
||||
|
||||
it('should call response.complete() on successful completion', async () => {
|
||||
const agent = container.get<CodexChatAgent>(CodexChatAgent);
|
||||
const mockCodexService = container.get<CodexFrontendService>(CodexFrontendService);
|
||||
|
||||
const events = [
|
||||
createAgentMessageEvent('Success'),
|
||||
createTurnCompletedEvent(10, 5)
|
||||
];
|
||||
(mockCodexService.send as sinon.SinonStub).resolves(createMockStream(events));
|
||||
|
||||
await agent.invoke(mockRequest);
|
||||
|
||||
const completeStub = (mockRequest.response.complete as sinon.SinonStub);
|
||||
const errorStub = (mockRequest.response.error as sinon.SinonStub);
|
||||
expect(completeStub.calledOnce).to.be.true;
|
||||
expect(errorStub.called).to.be.false;
|
||||
});
|
||||
|
||||
it('should call response.error() on failure', async () => {
|
||||
const agent = container.get<CodexChatAgent>(CodexChatAgent);
|
||||
const mockCodexService = container.get<CodexFrontendService>(CodexFrontendService);
|
||||
|
||||
const error = new Error('Test error');
|
||||
(mockCodexService.send as sinon.SinonStub).rejects(error);
|
||||
|
||||
await agent.invoke(mockRequest);
|
||||
|
||||
const errorStub = (mockRequest.response.error as sinon.SinonStub);
|
||||
expect(errorStub.calledWith(error)).to.be.true;
|
||||
});
|
||||
|
||||
it('should extract prompt and session ID correctly', async () => {
|
||||
const agent = container.get<CodexChatAgent>(CodexChatAgent);
|
||||
const mockCodexService = container.get<CodexFrontendService>(CodexFrontendService);
|
||||
|
||||
const customRequest = {
|
||||
...mockRequest,
|
||||
request: { text: '@Codex write a test' },
|
||||
session: {
|
||||
id: 'session-123',
|
||||
getRequests: mockRequest.session.getRequests,
|
||||
setSuggestions: mockRequest.session.setSuggestions
|
||||
}
|
||||
} as unknown as MutableChatRequestModel;
|
||||
const events = [createTurnCompletedEvent(10, 5)];
|
||||
(mockCodexService.send as sinon.SinonStub).resolves(createMockStream(events));
|
||||
|
||||
await agent.invoke(customRequest);
|
||||
|
||||
expect((mockCodexService.send as sinon.SinonStub).calledOnce).to.be.true;
|
||||
const callArgs = (mockCodexService.send as sinon.SinonStub).firstCall.args[0];
|
||||
expect(callArgs.prompt).to.equal('write a test');
|
||||
expect(callArgs.sessionId).to.equal('session-123');
|
||||
});
|
||||
|
||||
it('should pass sandboxMode from modeId to codexService.send', async () => {
|
||||
const agent = container.get<CodexChatAgent>(CodexChatAgent);
|
||||
const mockCodexService = container.get<CodexFrontendService>(CodexFrontendService);
|
||||
|
||||
const customRequest = {
|
||||
...mockRequest,
|
||||
request: { text: 'test prompt', modeId: 'read-only' },
|
||||
session: mockRequest.session
|
||||
} as unknown as MutableChatRequestModel;
|
||||
const events = [createTurnCompletedEvent(10, 5)];
|
||||
(mockCodexService.send as sinon.SinonStub).resolves(createMockStream(events));
|
||||
|
||||
await agent.invoke(customRequest);
|
||||
|
||||
expect((mockCodexService.send as sinon.SinonStub).calledOnce).to.be.true;
|
||||
const callArgs = (mockCodexService.send as sinon.SinonStub).firstCall.args[0];
|
||||
expect(callArgs.sandboxMode).to.equal('read-only');
|
||||
});
|
||||
|
||||
it('should default sandboxMode to workspace-write when modeId is undefined', async () => {
|
||||
const agent = container.get<CodexChatAgent>(CodexChatAgent);
|
||||
const mockCodexService = container.get<CodexFrontendService>(CodexFrontendService);
|
||||
|
||||
const customRequest = {
|
||||
...mockRequest,
|
||||
request: { text: 'test prompt' },
|
||||
session: mockRequest.session
|
||||
} as unknown as MutableChatRequestModel;
|
||||
const events = [createTurnCompletedEvent(10, 5)];
|
||||
(mockCodexService.send as sinon.SinonStub).resolves(createMockStream(events));
|
||||
|
||||
await agent.invoke(customRequest);
|
||||
|
||||
expect((mockCodexService.send as sinon.SinonStub).calledOnce).to.be.true;
|
||||
const callArgs = (mockCodexService.send as sinon.SinonStub).firstCall.args[0];
|
||||
expect(callArgs.sandboxMode).to.equal('workspace-write');
|
||||
});
|
||||
|
||||
it('should default sandboxMode to workspace-write when modeId is invalid', async () => {
|
||||
const agent = container.get<CodexChatAgent>(CodexChatAgent);
|
||||
const mockCodexService = container.get<CodexFrontendService>(CodexFrontendService);
|
||||
|
||||
const customRequest = {
|
||||
...mockRequest,
|
||||
request: { text: 'test prompt', modeId: 'invalid-mode' },
|
||||
session: mockRequest.session
|
||||
} as unknown as MutableChatRequestModel;
|
||||
const events = [createTurnCompletedEvent(10, 5)];
|
||||
(mockCodexService.send as sinon.SinonStub).resolves(createMockStream(events));
|
||||
|
||||
await agent.invoke(customRequest);
|
||||
|
||||
expect((mockCodexService.send as sinon.SinonStub).calledOnce).to.be.true;
|
||||
const callArgs = (mockCodexService.send as sinon.SinonStub).firstCall.args[0];
|
||||
expect(callArgs.sandboxMode).to.equal('workspace-write');
|
||||
});
|
||||
});
|
||||
|
||||
describe('protected methods', () => {
|
||||
it('getToolCalls should create new map if not exists', () => {
|
||||
const agent = container.get<CodexChatAgent>(CodexChatAgent);
|
||||
(mockRequest.getDataByKey as sinon.SinonStub).withArgs(CODEX_TOOL_CALLS_KEY).returns(undefined);
|
||||
|
||||
const testAccess = agent as unknown as CodexChatAgentTestAccess;
|
||||
const result = testAccess.getToolCalls(mockRequest);
|
||||
|
||||
expect(result).to.be.instanceOf(Map);
|
||||
expect(result.size).to.equal(0);
|
||||
expect((mockRequest.addData as sinon.SinonStub).calledWith(CODEX_TOOL_CALLS_KEY, result)).to.be.true;
|
||||
});
|
||||
|
||||
it('getToolCalls should return existing map', () => {
|
||||
const agent = container.get<CodexChatAgent>(CodexChatAgent);
|
||||
const existingMap = new Map<string, unknown>();
|
||||
existingMap.set('test-id', {});
|
||||
(mockRequest.getDataByKey as sinon.SinonStub).withArgs(CODEX_TOOL_CALLS_KEY).returns(existingMap);
|
||||
|
||||
const testAccess = agent as unknown as CodexChatAgentTestAccess;
|
||||
const result = testAccess.getToolCalls(mockRequest);
|
||||
|
||||
expect(result).to.equal(existingMap);
|
||||
expect(result.size).to.equal(1);
|
||||
expect((mockRequest.addData as sinon.SinonStub).called).to.be.false;
|
||||
});
|
||||
|
||||
it('isToolInvocation should return true for command_execution item', () => {
|
||||
const agent = container.get<CodexChatAgent>(CodexChatAgent);
|
||||
const item = {
|
||||
type: 'command_execution',
|
||||
id: 'cmd-1',
|
||||
command: 'npm test',
|
||||
status: 'running',
|
||||
aggregated_output: ''
|
||||
} as unknown as CommandExecutionItem;
|
||||
|
||||
const testAccess = agent as unknown as CodexChatAgentTestAccess;
|
||||
expect(testAccess.isToolInvocation(item)).to.be.true;
|
||||
});
|
||||
|
||||
it('isToolInvocation should return true for file_change item', () => {
|
||||
const agent = container.get<CodexChatAgent>(CodexChatAgent);
|
||||
const item = {
|
||||
type: 'file_change',
|
||||
id: 'file-1',
|
||||
changes: [],
|
||||
status: 'running'
|
||||
} as unknown as FileChangeItem;
|
||||
|
||||
const testAccess = agent as unknown as CodexChatAgentTestAccess;
|
||||
expect(testAccess.isToolInvocation(item)).to.be.true;
|
||||
});
|
||||
|
||||
it('isToolInvocation should return true for mcp_tool_call item', () => {
|
||||
const agent = container.get<CodexChatAgent>(CodexChatAgent);
|
||||
const item = {
|
||||
type: 'mcp_tool_call',
|
||||
id: 'mcp-1',
|
||||
server: 'test-server',
|
||||
tool: 'test-tool',
|
||||
status: 'running'
|
||||
} as unknown as McpToolCallItem;
|
||||
|
||||
const testAccess = agent as unknown as CodexChatAgentTestAccess;
|
||||
expect(testAccess.isToolInvocation(item)).to.be.true;
|
||||
});
|
||||
|
||||
it('isToolInvocation should return true for web_search item', () => {
|
||||
const agent = container.get<CodexChatAgent>(CodexChatAgent);
|
||||
const item: WebSearchItem = {
|
||||
type: 'web_search',
|
||||
id: 'search-1',
|
||||
query: 'test query'
|
||||
};
|
||||
|
||||
const testAccess = agent as unknown as CodexChatAgentTestAccess;
|
||||
expect(testAccess.isToolInvocation(item)).to.be.true;
|
||||
});
|
||||
|
||||
it('isToolInvocation should return true for todo_list item', () => {
|
||||
const agent = container.get<CodexChatAgent>(CodexChatAgent);
|
||||
const item: TodoListItem = {
|
||||
type: 'todo_list',
|
||||
id: 'todo-1',
|
||||
items: []
|
||||
};
|
||||
|
||||
const testAccess = agent as unknown as CodexChatAgentTestAccess;
|
||||
expect(testAccess.isToolInvocation(item)).to.be.true;
|
||||
});
|
||||
|
||||
it('isToolInvocation should return false for agent_message item', () => {
|
||||
const agent = container.get<CodexChatAgent>(CodexChatAgent);
|
||||
const item: AgentMessageItem = {
|
||||
type: 'agent_message',
|
||||
id: 'msg-1',
|
||||
text: 'Hello'
|
||||
};
|
||||
|
||||
const testAccess = agent as unknown as CodexChatAgentTestAccess;
|
||||
expect(testAccess.isToolInvocation(item)).to.be.false;
|
||||
});
|
||||
|
||||
it('extractToolArguments should extract command_execution arguments', () => {
|
||||
const agent = container.get<CodexChatAgent>(CodexChatAgent);
|
||||
const item: CommandExecutionItem = {
|
||||
type: 'command_execution',
|
||||
id: 'cmd-1',
|
||||
command: 'npm test',
|
||||
status: 'completed',
|
||||
exit_code: 0,
|
||||
aggregated_output: 'test output'
|
||||
};
|
||||
|
||||
const testAccess = agent as unknown as CodexChatAgentTestAccess;
|
||||
const result = testAccess.extractToolArguments(item);
|
||||
const parsed = JSON.parse(result);
|
||||
|
||||
expect(parsed.command).to.equal('npm test');
|
||||
expect(parsed.status).to.equal('completed');
|
||||
expect(parsed.exit_code).to.equal(0);
|
||||
});
|
||||
|
||||
it('extractToolArguments should extract file_change arguments', () => {
|
||||
const agent = container.get<CodexChatAgent>(CodexChatAgent);
|
||||
const item: FileChangeItem = {
|
||||
type: 'file_change',
|
||||
id: 'file-1',
|
||||
changes: [{ path: '/test/file.ts', kind: 'add' }],
|
||||
status: 'completed'
|
||||
};
|
||||
|
||||
const testAccess = agent as unknown as CodexChatAgentTestAccess;
|
||||
const result = testAccess.extractToolArguments(item);
|
||||
const parsed = JSON.parse(result);
|
||||
|
||||
expect(parsed.changes).to.deep.equal([{ path: '/test/file.ts', kind: 'add' }]);
|
||||
expect(parsed.status).to.equal('completed');
|
||||
});
|
||||
|
||||
it('updateTokens should store token data and update session suggestion', () => {
|
||||
const agent = container.get<CodexChatAgent>(CodexChatAgent);
|
||||
(mockRequest.session.getRequests as sinon.SinonStub).returns([mockRequest]);
|
||||
(mockRequest.getDataByKey as sinon.SinonStub).withArgs(CODEX_INPUT_TOKENS_KEY).returns(100);
|
||||
(mockRequest.getDataByKey as sinon.SinonStub).withArgs(CODEX_OUTPUT_TOKENS_KEY).returns(50);
|
||||
|
||||
const testAccess = agent as unknown as CodexChatAgentTestAccess;
|
||||
testAccess.updateTokens(mockRequest, 100, 50);
|
||||
|
||||
expect((mockRequest.addData as sinon.SinonStub).calledWith(CODEX_INPUT_TOKENS_KEY, 100)).to.be.true;
|
||||
expect((mockRequest.addData as sinon.SinonStub).calledWith(CODEX_OUTPUT_TOKENS_KEY, 50)).to.be.true;
|
||||
expect((mockRequest.session.setSuggestions as sinon.SinonStub).called).to.be.true;
|
||||
});
|
||||
|
||||
it('getSessionTotalTokens should sum tokens across multiple requests', () => {
|
||||
const agent = container.get<CodexChatAgent>(CodexChatAgent);
|
||||
const request1 = {
|
||||
getDataByKey: sinon.stub()
|
||||
};
|
||||
(request1.getDataByKey as sinon.SinonStub).withArgs(CODEX_INPUT_TOKENS_KEY).returns(100);
|
||||
(request1.getDataByKey as sinon.SinonStub).withArgs(CODEX_OUTPUT_TOKENS_KEY).returns(50);
|
||||
|
||||
const request2 = {
|
||||
getDataByKey: sinon.stub()
|
||||
};
|
||||
(request2.getDataByKey as sinon.SinonStub).withArgs(CODEX_INPUT_TOKENS_KEY).returns(200);
|
||||
(request2.getDataByKey as sinon.SinonStub).withArgs(CODEX_OUTPUT_TOKENS_KEY).returns(75);
|
||||
|
||||
(mockRequest.session.getRequests as sinon.SinonStub).returns([request1, request2]);
|
||||
|
||||
const testAccess = agent as unknown as CodexChatAgentTestAccess;
|
||||
const result = testAccess.getSessionTotalTokens(mockRequest);
|
||||
|
||||
expect(result.inputTokens).to.equal(300);
|
||||
expect(result.outputTokens).to.equal(125);
|
||||
});
|
||||
|
||||
it('getSessionTotalTokens should handle requests with no token data', () => {
|
||||
const agent = container.get<CodexChatAgent>(CodexChatAgent);
|
||||
const request1 = {
|
||||
getDataByKey: sinon.stub().returns(undefined)
|
||||
};
|
||||
|
||||
(mockRequest.session.getRequests as sinon.SinonStub).returns([request1]);
|
||||
|
||||
const testAccess = agent as unknown as CodexChatAgentTestAccess;
|
||||
const result = testAccess.getSessionTotalTokens(mockRequest);
|
||||
|
||||
expect(result.inputTokens).to.equal(0);
|
||||
expect(result.outputTokens).to.equal(0);
|
||||
});
|
||||
|
||||
it('extractSandboxMode should return read-only for read-only modeId', () => {
|
||||
const agent = container.get<CodexChatAgent>(CodexChatAgent);
|
||||
const testAccess = agent as unknown as CodexChatAgentTestAccess;
|
||||
expect(testAccess.extractSandboxMode('read-only')).to.equal('read-only');
|
||||
});
|
||||
|
||||
it('extractSandboxMode should return workspace-write for workspace-write modeId', () => {
|
||||
const agent = container.get<CodexChatAgent>(CodexChatAgent);
|
||||
const testAccess = agent as unknown as CodexChatAgentTestAccess;
|
||||
expect(testAccess.extractSandboxMode('workspace-write')).to.equal('workspace-write');
|
||||
});
|
||||
|
||||
it('extractSandboxMode should return danger-full-access for danger-full-access modeId', () => {
|
||||
const agent = container.get<CodexChatAgent>(CodexChatAgent);
|
||||
const testAccess = agent as unknown as CodexChatAgentTestAccess;
|
||||
expect(testAccess.extractSandboxMode('danger-full-access')).to.equal('danger-full-access');
|
||||
});
|
||||
|
||||
it('extractSandboxMode should default to workspace-write for undefined modeId', () => {
|
||||
const agent = container.get<CodexChatAgent>(CodexChatAgent);
|
||||
const testAccess = agent as unknown as CodexChatAgentTestAccess;
|
||||
expect(testAccess.extractSandboxMode(undefined)).to.equal('workspace-write');
|
||||
});
|
||||
|
||||
it('extractSandboxMode should default to workspace-write for invalid modeId', () => {
|
||||
const agent = container.get<CodexChatAgent>(CodexChatAgent);
|
||||
const testAccess = agent as unknown as CodexChatAgentTestAccess;
|
||||
expect(testAccess.extractSandboxMode('invalid-mode')).to.equal('workspace-write');
|
||||
});
|
||||
});
|
||||
});
|
||||
654
packages/ai-codex/src/browser/codex-chat-agent.ts
Normal file
654
packages/ai-codex/src/browser/codex-chat-agent.ts
Normal file
@@ -0,0 +1,654 @@
|
||||
// *****************************************************************************
|
||||
// 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 {
|
||||
ChatAgent,
|
||||
ChatAgentLocation,
|
||||
ErrorChatResponseContentImpl,
|
||||
MarkdownChatResponseContentImpl,
|
||||
MutableChatRequestModel,
|
||||
ThinkingChatResponseContentImpl,
|
||||
ToolCallChatResponseContent,
|
||||
} from '@theia/ai-chat';
|
||||
import { TokenUsageService } from '@theia/ai-core';
|
||||
import { PromptText } from '@theia/ai-core/lib/common/prompt-text';
|
||||
import { generateUuid, nls } from '@theia/core';
|
||||
import { URI } from '@theia/core/lib/common/uri';
|
||||
import { inject, injectable } from '@theia/core/shared/inversify';
|
||||
import type {
|
||||
ItemStartedEvent,
|
||||
ItemUpdatedEvent,
|
||||
ItemCompletedEvent,
|
||||
TurnCompletedEvent,
|
||||
TurnFailedEvent,
|
||||
ThreadEvent,
|
||||
ThreadItem,
|
||||
CommandExecutionItem,
|
||||
FileChangeItem,
|
||||
McpToolCallItem,
|
||||
WebSearchItem,
|
||||
Usage,
|
||||
TodoListItem
|
||||
} from '@openai/codex-sdk';
|
||||
import { FileService } from '@theia/filesystem/lib/browser/file-service';
|
||||
import { WorkspaceService } from '@theia/workspace/lib/browser';
|
||||
import { ChangeSetFileElementFactory } from '@theia/ai-chat/lib/browser/change-set-file-element';
|
||||
import { CodexToolCallChatResponseContent } from './codex-tool-call-content';
|
||||
import { CodexFrontendService } from './codex-frontend-service';
|
||||
|
||||
export const CODEX_CHAT_AGENT_ID = 'Codex';
|
||||
export const CODEX_INPUT_TOKENS_KEY = 'codexInputTokens';
|
||||
export const CODEX_OUTPUT_TOKENS_KEY = 'codexOutputTokens';
|
||||
export const CODEX_TOOL_CALLS_KEY = 'codexToolCalls';
|
||||
|
||||
const CODEX_FILE_CHANGE_ORIGINALS_KEY = 'codexFileChangeOriginals';
|
||||
// const CODEX_CHANGESET_TITLE = nls.localize('theia/ai/codex/changeSetTitle', 'Codex Applied Changes');
|
||||
|
||||
type ToolInvocationItem = CommandExecutionItem | FileChangeItem | McpToolCallItem | WebSearchItem | TodoListItem;
|
||||
|
||||
/**
|
||||
* Chat agent for OpenAI Codex integration.
|
||||
*/
|
||||
@injectable()
|
||||
export class CodexChatAgent implements ChatAgent {
|
||||
id = CODEX_CHAT_AGENT_ID;
|
||||
name = 'Codex';
|
||||
description = nls.localize('theia/ai/codex/agentDescription',
|
||||
'OpenAI\'s coding assistant powered by Codex');
|
||||
iconClass = 'codicon codicon-robot';
|
||||
locations: ChatAgentLocation[] = ChatAgentLocation.ALL;
|
||||
tags = [nls.localizeByDefault('Chat')];
|
||||
variables: string[] = [];
|
||||
prompts: [] = [];
|
||||
languageModelRequirements: [] = [];
|
||||
agentSpecificVariables: [] = [];
|
||||
functions: string[] = [];
|
||||
modes = [
|
||||
{ id: 'workspace-write', name: 'Workspace' },
|
||||
{ id: 'read-only', name: 'Read-Only' },
|
||||
{ id: 'danger-full-access', name: 'Full Access' }
|
||||
];
|
||||
|
||||
@inject(CodexFrontendService)
|
||||
protected codexService: CodexFrontendService;
|
||||
|
||||
@inject(TokenUsageService)
|
||||
protected tokenUsageService: TokenUsageService;
|
||||
|
||||
@inject(FileService)
|
||||
protected readonly fileService: FileService;
|
||||
|
||||
@inject(WorkspaceService)
|
||||
protected readonly workspaceService: WorkspaceService;
|
||||
|
||||
@inject(ChangeSetFileElementFactory)
|
||||
protected readonly fileChangeFactory: ChangeSetFileElementFactory;
|
||||
|
||||
async invoke(request: MutableChatRequestModel): Promise<void> {
|
||||
try {
|
||||
const agentAddress = `${PromptText.AGENT_CHAR}${CODEX_CHAT_AGENT_ID}`;
|
||||
let prompt = request.request.text.trim();
|
||||
if (prompt.startsWith(agentAddress)) {
|
||||
prompt = prompt.replace(agentAddress, '').trim();
|
||||
}
|
||||
|
||||
const sessionId = request.session.id;
|
||||
const sandboxMode = this.extractSandboxMode(request.request.modeId);
|
||||
const streamResult = await this.codexService.send(
|
||||
{ prompt, sessionId, sandboxMode },
|
||||
request.response.cancellationToken
|
||||
);
|
||||
|
||||
for await (const event of streamResult) {
|
||||
await this.handleEvent(event, request);
|
||||
}
|
||||
|
||||
request.response.complete();
|
||||
} catch (error) {
|
||||
console.error('Codex error:', error);
|
||||
request.response.response.addContent(
|
||||
new ErrorChatResponseContentImpl(error)
|
||||
);
|
||||
request.response.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
protected extractSandboxMode(modeId?: string): 'read-only' | 'workspace-write' | 'danger-full-access' {
|
||||
if (modeId === 'read-only' || modeId === 'workspace-write' || modeId === 'danger-full-access') {
|
||||
return modeId;
|
||||
}
|
||||
return 'workspace-write';
|
||||
}
|
||||
|
||||
protected getToolCalls(request: MutableChatRequestModel): Map<string, CodexToolCallChatResponseContent> {
|
||||
let toolCalls = request.getDataByKey(CODEX_TOOL_CALLS_KEY) as Map<string, CodexToolCallChatResponseContent> | undefined;
|
||||
if (!toolCalls) {
|
||||
toolCalls = new Map();
|
||||
request.addData(CODEX_TOOL_CALLS_KEY, toolCalls);
|
||||
}
|
||||
return toolCalls;
|
||||
}
|
||||
|
||||
protected async handleEvent(event: ThreadEvent, request: MutableChatRequestModel): Promise<void> {
|
||||
if (event.type === 'item.started') {
|
||||
await this.handleItemStarted(event, request);
|
||||
} else if (event.type === 'item.updated') {
|
||||
await this.handleItemUpdated(event, request);
|
||||
} else if (event.type === 'item.completed') {
|
||||
await this.handleItemCompleted(event, request);
|
||||
} else if (event.type === 'turn.completed') {
|
||||
this.handleTurnCompleted(event, request);
|
||||
} else if (event.type === 'turn.failed') {
|
||||
this.handleTurnFailed(event, request);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard using discriminated union narrowing from SDK types.
|
||||
*/
|
||||
protected isToolInvocation(item: ThreadItem): item is ToolInvocationItem {
|
||||
return item.type === 'command_execution' ||
|
||||
item.type === 'todo_list' ||
|
||||
item.type === 'file_change' ||
|
||||
item.type === 'mcp_tool_call' ||
|
||||
item.type === 'web_search';
|
||||
}
|
||||
|
||||
protected extractToolArguments(item: ToolInvocationItem): string {
|
||||
const args: Record<string, unknown> = {};
|
||||
|
||||
if (item.type === 'command_execution') {
|
||||
args.command = item.command;
|
||||
args.status = item.status;
|
||||
if (item.exit_code !== undefined) {
|
||||
args.exit_code = item.exit_code;
|
||||
}
|
||||
} else if (item.type === 'file_change') {
|
||||
args.changes = item.changes;
|
||||
args.status = item.status;
|
||||
} else if (item.type === 'mcp_tool_call') {
|
||||
args.server = item.server;
|
||||
args.tool = item.tool;
|
||||
args.status = item.status;
|
||||
} else if (item.type === 'web_search') {
|
||||
args.query = item.query;
|
||||
} else if (item.type === 'todo_list') {
|
||||
args.id = item.id;
|
||||
args.items = item.items;
|
||||
}
|
||||
|
||||
return JSON.stringify(args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a pending tool call that will be updated when the item completes.
|
||||
*/
|
||||
protected async handleItemStarted(event: ItemStartedEvent, request: MutableChatRequestModel): Promise<void> {
|
||||
const item = event.item;
|
||||
|
||||
if (this.isToolInvocation(item)) {
|
||||
if (item.type === 'file_change') {
|
||||
await this.captureFileChangeOriginals(item, request);
|
||||
return;
|
||||
}
|
||||
const toolCallId = generateUuid();
|
||||
const args = this.extractToolArguments(item);
|
||||
|
||||
const toolCall = new CodexToolCallChatResponseContent(
|
||||
toolCallId,
|
||||
item.type,
|
||||
args,
|
||||
false,
|
||||
undefined
|
||||
);
|
||||
|
||||
this.getToolCalls(request).set(toolCallId, toolCall);
|
||||
request.response.response.addContent(toolCall);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the pending tool call with new data, especially for todo_list items.
|
||||
*/
|
||||
protected async handleItemUpdated(event: ItemUpdatedEvent, request: MutableChatRequestModel): Promise<void> {
|
||||
const item = event.item;
|
||||
|
||||
if (this.isToolInvocation(item)) {
|
||||
const toolCalls = this.getToolCalls(request);
|
||||
const match = this.findMatchingToolCall(item, toolCalls);
|
||||
|
||||
if (match) {
|
||||
const [_, existingCall] = match;
|
||||
existingCall.update(this.extractToolArguments(item));
|
||||
request.response.response.responseContentChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected findMatchingToolCall(
|
||||
item: ToolInvocationItem,
|
||||
toolCalls: Map<string, CodexToolCallChatResponseContent>
|
||||
): [string, CodexToolCallChatResponseContent] | undefined {
|
||||
let matchKey: string | undefined;
|
||||
if (item.type === 'command_execution') {
|
||||
matchKey = item.command;
|
||||
} else if (item.type === 'web_search') {
|
||||
matchKey = item.query;
|
||||
} else if (item.type === 'mcp_tool_call') {
|
||||
matchKey = `${item.server}:${item.tool}`;
|
||||
} else if (item.type === 'todo_list') {
|
||||
matchKey = item.id;
|
||||
}
|
||||
|
||||
if (!matchKey) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const [id, call] of toolCalls.entries()) {
|
||||
const toolCallContent = call as ToolCallChatResponseContent;
|
||||
if (toolCallContent.name !== item.type || toolCallContent.finished) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const args = toolCallContent.arguments ? JSON.parse(toolCallContent.arguments) : {};
|
||||
let argKey: string | undefined;
|
||||
|
||||
if (item.type === 'command_execution') {
|
||||
argKey = args.command;
|
||||
} else if (item.type === 'web_search') {
|
||||
argKey = args.query;
|
||||
} else if (item.type === 'mcp_tool_call') {
|
||||
argKey = `${args.server}:${args.tool}`;
|
||||
} else if (item.type === 'todo_list') {
|
||||
argKey = args.id;
|
||||
}
|
||||
|
||||
if (argKey === matchKey) {
|
||||
return [id, call];
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
protected getFileChangeOriginals(request: MutableChatRequestModel): Map<string, Map<string, string>> {
|
||||
let originals = request.getDataByKey(CODEX_FILE_CHANGE_ORIGINALS_KEY) as Map<string, Map<string, string>> | undefined;
|
||||
if (!originals) {
|
||||
originals = new Map();
|
||||
request.addData(CODEX_FILE_CHANGE_ORIGINALS_KEY, originals);
|
||||
}
|
||||
return originals;
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot the original contents for files that Codex is about to modify so we can populate the change set later.
|
||||
*/
|
||||
protected async captureFileChangeOriginals(item: FileChangeItem, request: MutableChatRequestModel): Promise<void> {
|
||||
const changes = item.changes;
|
||||
if (!changes || changes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rootUri = await this.getWorkspaceRootUri();
|
||||
if (!rootUri) {
|
||||
return;
|
||||
}
|
||||
|
||||
const originals = this.getFileChangeOriginals(request);
|
||||
let itemOriginals = originals.get(item.id);
|
||||
if (!itemOriginals) {
|
||||
itemOriginals = new Map();
|
||||
originals.set(item.id, itemOriginals);
|
||||
}
|
||||
|
||||
for (const change of changes) {
|
||||
const rawPath = typeof change.path === 'string' ? change.path.trim() : '';
|
||||
const path = this.normalizeRelativePath(rawPath, rootUri);
|
||||
if (!path) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fileUri = this.resolveFileUri(rootUri, path);
|
||||
if (!fileUri) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// For additions we snapshot an empty original state; for deletions/updates we capture existing content if available.
|
||||
if (change.kind === 'add') {
|
||||
itemOriginals.set(path, '');
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
if (await this.fileService.exists(fileUri)) {
|
||||
const currentContent = await this.fileService.read(fileUri);
|
||||
itemOriginals.set(path, currentContent.value.toString());
|
||||
} else {
|
||||
itemOriginals.set(path, '');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('CodexChatAgent: Failed to capture original content for', path, error);
|
||||
itemOriginals.set(path, '');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected async handleFileChangeCompleted(item: FileChangeItem, request: MutableChatRequestModel): Promise<boolean> {
|
||||
if (!item.changes || item.changes.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const originals = this.getFileChangeOriginals(request);
|
||||
|
||||
if (item.status === 'failed') {
|
||||
const affectedPaths = item.changes
|
||||
.map(change => change.path)
|
||||
.filter(path => !!path)
|
||||
.join(', ');
|
||||
const message = affectedPaths.length > 0
|
||||
? nls.localize('theia/ai/codex/fileChangeFailed', 'Codex failed to apply changes for: {0}', affectedPaths)
|
||||
: nls.localize('theia/ai/codex/fileChangeFailedGeneric', 'Codex failed to apply file changes.');
|
||||
request.response.response.addContent(
|
||||
new ErrorChatResponseContentImpl(new Error(message))
|
||||
);
|
||||
originals.delete(item.id);
|
||||
return true;
|
||||
}
|
||||
|
||||
// const rootUri = await this.getWorkspaceRootUri();
|
||||
// if (!rootUri) {
|
||||
// console.warn('CodexChatAgent: Unable to resolve workspace root for file change event.');
|
||||
// return false;
|
||||
// }
|
||||
|
||||
// const changeSet = request.session?.changeSet;
|
||||
// if (!changeSet) {
|
||||
// originals.delete(item.id);
|
||||
// return false;
|
||||
// }
|
||||
|
||||
// const itemOriginals = originals.get(item.id);
|
||||
// let createdElement = false;
|
||||
|
||||
// for (const change of item.changes) {
|
||||
// const rawPath = typeof change.path === 'string' ? change.path.trim() : '';
|
||||
// const path = this.normalizeRelativePath(rawPath, rootUri);
|
||||
// if (!path) {
|
||||
// continue;
|
||||
// }
|
||||
|
||||
// const fileUri = this.resolveFileUri(rootUri, path);
|
||||
// if (!fileUri) {
|
||||
// continue;
|
||||
// }
|
||||
|
||||
// const originalState = itemOriginals?.get(path) ?? '';
|
||||
// let targetState = '';
|
||||
|
||||
// if (change.kind !== 'delete') {
|
||||
// const content = await this.readFileContentSafe(fileUri);
|
||||
// if (content === undefined) {
|
||||
// continue;
|
||||
// }
|
||||
// targetState = content;
|
||||
// }
|
||||
|
||||
// const elementType = this.mapChangeKind(change.kind);
|
||||
// const fileElement = this.fileChangeFactory({
|
||||
// uri: fileUri,
|
||||
// type: elementType,
|
||||
// state: 'applied',
|
||||
// targetState,
|
||||
// originalState,
|
||||
// requestId: request.id,
|
||||
// chatSessionId: request.session.id
|
||||
// });
|
||||
|
||||
// changeSet.addElements(fileElement);
|
||||
// createdElement = true;
|
||||
// }
|
||||
|
||||
originals.delete(item.id);
|
||||
|
||||
// if (createdElement) {
|
||||
// changeSet.setTitle(CODEX_CHANGESET_TITLE);
|
||||
// }
|
||||
return false;
|
||||
}
|
||||
|
||||
protected normalizeRelativePath(path: string, rootUri?: URI): string | undefined {
|
||||
if (!path) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let normalized = path.replace(/\\/g, '/').trim();
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (normalized.includes('://')) {
|
||||
try {
|
||||
const uri = new URI(normalized);
|
||||
normalized = uri.path.fsPath();
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
if (/^[a-zA-Z]:\//.test(normalized)) {
|
||||
normalized = `/${normalized}`;
|
||||
}
|
||||
|
||||
if (rootUri) {
|
||||
const candidates = [
|
||||
this.ensureTrailingSlash(rootUri.path.normalize().toString()),
|
||||
this.ensureTrailingSlash(rootUri.path.fsPath().replace(/\\/g, '/'))
|
||||
];
|
||||
|
||||
const lowerNormalized = normalized.toLowerCase();
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate) {
|
||||
continue;
|
||||
}
|
||||
const lowerCandidate = candidate.toLowerCase();
|
||||
if (lowerNormalized.startsWith(lowerCandidate)) {
|
||||
normalized = normalized.substring(candidate.length);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (normalized.startsWith('./')) {
|
||||
normalized = normalized.substring(2);
|
||||
}
|
||||
while (normalized.startsWith('/')) {
|
||||
normalized = normalized.substring(1);
|
||||
}
|
||||
|
||||
normalized = normalized.trim();
|
||||
return normalized || undefined;
|
||||
}
|
||||
|
||||
protected ensureTrailingSlash(path: string): string {
|
||||
if (!path) {
|
||||
return '';
|
||||
}
|
||||
return path.endsWith('/') ? path : `${path}/`;
|
||||
}
|
||||
|
||||
// protected async readFileContentSafe(fileUri: URI): Promise<string | undefined> {
|
||||
// try {
|
||||
// if (!await this.fileService.exists(fileUri)) {
|
||||
// console.warn('CodexChatAgent: Skipping file change entry because file is missing', fileUri.toString());
|
||||
// return undefined;
|
||||
// }
|
||||
// const fileContent = await this.fileService.read(fileUri);
|
||||
// return fileContent.value.toString();
|
||||
// } catch (error) {
|
||||
// console.error('CodexChatAgent: Failed to read updated file content for', fileUri.toString(), error);
|
||||
// return undefined;
|
||||
// }
|
||||
// }
|
||||
|
||||
// protected mapChangeKind(kind: FileChangeItem['changes'][number]['kind']): 'add' | 'delete' | 'modify' {
|
||||
// switch (kind) {
|
||||
// case 'add':
|
||||
// return 'add';
|
||||
// case 'delete':
|
||||
// return 'delete';
|
||||
// default:
|
||||
// return 'modify';
|
||||
// }
|
||||
// }
|
||||
|
||||
protected resolveFileUri(rootUri: URI, relativePath: string): URI | undefined {
|
||||
try {
|
||||
const candidate = rootUri.resolve(relativePath);
|
||||
const normalizedCandidate = candidate.withPath(candidate.path.normalize());
|
||||
const normalizedRoot = rootUri.withPath(rootUri.path.normalize());
|
||||
if (!normalizedRoot.isEqualOrParent(normalizedCandidate)) {
|
||||
console.warn(`CodexChatAgent: Skipping file change outside workspace: ${relativePath}`);
|
||||
return undefined;
|
||||
}
|
||||
return normalizedCandidate;
|
||||
} catch (error) {
|
||||
console.error('CodexChatAgent: Failed to resolve file URI for', relativePath, error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
protected async getWorkspaceRootUri(): Promise<URI | undefined> {
|
||||
const roots = await this.workspaceService.roots;
|
||||
if (roots && roots.length > 0) {
|
||||
return roots[0].resource;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
protected async handleItemCompleted(event: ItemCompletedEvent, request: MutableChatRequestModel): Promise<void> {
|
||||
const item = event.item;
|
||||
|
||||
if (this.isToolInvocation(item)) {
|
||||
if (item.type === 'file_change') {
|
||||
const handled = await this.handleFileChangeCompleted(item, request);
|
||||
if (handled) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const toolCalls = this.getToolCalls(request);
|
||||
const match = this.findMatchingToolCall(item, toolCalls);
|
||||
|
||||
if (match) {
|
||||
const [id, _] = match;
|
||||
const updatedCall = new CodexToolCallChatResponseContent(
|
||||
id,
|
||||
item.type,
|
||||
this.extractToolArguments(item),
|
||||
true,
|
||||
JSON.stringify(item)
|
||||
);
|
||||
toolCalls.set(id, updatedCall);
|
||||
request.response.response.addContent(updatedCall);
|
||||
} else {
|
||||
const toolCallId = generateUuid();
|
||||
const newToolCall = new CodexToolCallChatResponseContent(
|
||||
toolCallId,
|
||||
item.type,
|
||||
this.extractToolArguments(item),
|
||||
true,
|
||||
JSON.stringify(item)
|
||||
);
|
||||
toolCalls.set(toolCallId, newToolCall);
|
||||
request.response.response.addContent(newToolCall);
|
||||
}
|
||||
} else if (item.type === 'reasoning') {
|
||||
request.response.response.addContent(
|
||||
new ThinkingChatResponseContentImpl(item.text, '')
|
||||
);
|
||||
|
||||
} else if (item.type === 'agent_message') {
|
||||
request.response.response.addContent(
|
||||
new MarkdownChatResponseContentImpl(item.text)
|
||||
);
|
||||
} else if (item.type === 'error') {
|
||||
request.response.response.addContent(
|
||||
new ErrorChatResponseContentImpl(new Error(item.message))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
protected handleTurnCompleted(event: TurnCompletedEvent, request: MutableChatRequestModel): void {
|
||||
const usage = event.usage;
|
||||
this.updateTokens(request, usage.input_tokens, usage.output_tokens);
|
||||
this.reportTokenUsage(request, usage);
|
||||
}
|
||||
|
||||
protected handleTurnFailed(event: TurnFailedEvent, request: MutableChatRequestModel): void {
|
||||
const errorMsg = event.error.message;
|
||||
request.response.response.addContent(
|
||||
new ErrorChatResponseContentImpl(new Error(errorMsg))
|
||||
);
|
||||
}
|
||||
|
||||
protected updateTokens(request: MutableChatRequestModel, inputTokens: number, outputTokens: number): void {
|
||||
request.addData(CODEX_INPUT_TOKENS_KEY, inputTokens);
|
||||
request.addData(CODEX_OUTPUT_TOKENS_KEY, outputTokens);
|
||||
this.updateSessionSuggestion(request);
|
||||
}
|
||||
|
||||
protected updateSessionSuggestion(request: MutableChatRequestModel): void {
|
||||
const { inputTokens, outputTokens } = this.getSessionTotalTokens(request);
|
||||
const formatTokens = (tokens: number): string => {
|
||||
if (tokens >= 1000) {
|
||||
return `${(tokens / 1000).toFixed(1)}K`;
|
||||
}
|
||||
return tokens.toString();
|
||||
};
|
||||
const suggestion = `↑ ${formatTokens(inputTokens)} | ↓ ${formatTokens(outputTokens)}`;
|
||||
request.session.setSuggestions([suggestion]);
|
||||
}
|
||||
|
||||
protected getSessionTotalTokens(request: MutableChatRequestModel): { inputTokens: number; outputTokens: number } {
|
||||
const requests = request.session.getRequests();
|
||||
let totalInputTokens = 0;
|
||||
let totalOutputTokens = 0;
|
||||
|
||||
for (const req of requests) {
|
||||
const inputTokens = req.getDataByKey(CODEX_INPUT_TOKENS_KEY) as number ?? 0;
|
||||
const outputTokens = req.getDataByKey(CODEX_OUTPUT_TOKENS_KEY) as number ?? 0;
|
||||
totalInputTokens += inputTokens;
|
||||
totalOutputTokens += outputTokens;
|
||||
}
|
||||
|
||||
return { inputTokens: totalInputTokens, outputTokens: totalOutputTokens };
|
||||
}
|
||||
|
||||
protected async reportTokenUsage(request: MutableChatRequestModel, usage: Usage): Promise<void> {
|
||||
try {
|
||||
await this.tokenUsageService.recordTokenUsage('openai/codex', {
|
||||
inputTokens: usage.input_tokens,
|
||||
outputTokens: usage.output_tokens,
|
||||
cachedInputTokens: usage.cached_input_tokens,
|
||||
requestId: request.id
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to report token usage:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
61
packages/ai-codex/src/browser/codex-frontend-module.ts
Normal file
61
packages/ai-codex/src/browser/codex-frontend-module.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
// *****************************************************************************
|
||||
// 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 { ChatAgent } from '@theia/ai-chat';
|
||||
import { ChatResponsePartRenderer } from '@theia/ai-chat-ui/lib/browser/chat-response-part-renderer';
|
||||
import { Agent } from '@theia/ai-core';
|
||||
import { PreferenceContribution } from '@theia/core';
|
||||
import { RemoteConnectionProvider, ServiceConnectionProvider } from '@theia/core/lib/browser';
|
||||
import { ContainerModule } from '@theia/core/shared/inversify';
|
||||
import {
|
||||
CODEX_SERVICE_PATH,
|
||||
CodexClient,
|
||||
CodexService
|
||||
} from '../common/codex-service';
|
||||
import { CodexPreferencesSchema } from '../common/codex-preferences';
|
||||
import { CodexChatAgent } from './codex-chat-agent';
|
||||
import { CodexClientImpl, CodexFrontendService } from './codex-frontend-service';
|
||||
import { CommandExecutionRenderer } from './renderers/command-execution-renderer';
|
||||
import { TodoListRenderer } from './renderers/todo-list-renderer';
|
||||
import { WebSearchRenderer } from './renderers/web-search-renderer';
|
||||
import '../../src/browser/style/codex-tool-renderers.css';
|
||||
|
||||
export default new ContainerModule(bind => {
|
||||
bind(PreferenceContribution).toConstantValue({ schema: CodexPreferencesSchema });
|
||||
|
||||
bind(CodexFrontendService).toSelf().inSingletonScope();
|
||||
bind(CodexClientImpl).toSelf().inSingletonScope();
|
||||
bind(CodexClient).toService(CodexClientImpl);
|
||||
|
||||
bind(CodexService).toDynamicValue(ctx => {
|
||||
const connection = ctx.container.get<ServiceConnectionProvider>(RemoteConnectionProvider);
|
||||
const backendClient: CodexClient = ctx.container.get(CodexClient);
|
||||
return connection.createProxy(CODEX_SERVICE_PATH, backendClient);
|
||||
}).inSingletonScope();
|
||||
|
||||
bind(CodexChatAgent).toSelf().inSingletonScope();
|
||||
bind(Agent).toService(CodexChatAgent);
|
||||
bind(ChatAgent).toService(CodexChatAgent);
|
||||
|
||||
bind(CommandExecutionRenderer).toSelf().inSingletonScope();
|
||||
bind(ChatResponsePartRenderer).toService(CommandExecutionRenderer);
|
||||
|
||||
bind(TodoListRenderer).toSelf().inSingletonScope();
|
||||
bind(ChatResponsePartRenderer).toService(TodoListRenderer);
|
||||
|
||||
bind(WebSearchRenderer).toSelf().inSingletonScope();
|
||||
bind(ChatResponsePartRenderer).toService(WebSearchRenderer);
|
||||
});
|
||||
245
packages/ai-codex/src/browser/codex-frontend-service.spec.ts
Normal file
245
packages/ai-codex/src/browser/codex-frontend-service.spec.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
// *****************************************************************************
|
||||
// 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 { enableJSDOM } from '@theia/core/lib/browser/test/jsdom';
|
||||
let disableJSDOM = enableJSDOM();
|
||||
import { FrontendApplicationConfigProvider } from '@theia/core/lib/browser/frontend-application-config-provider';
|
||||
FrontendApplicationConfigProvider.set({});
|
||||
import * as path from 'path';
|
||||
|
||||
import { expect } from 'chai';
|
||||
import * as sinon from 'sinon';
|
||||
import { Container, interfaces } from '@theia/core/shared/inversify';
|
||||
import { PreferenceService } from '@theia/core';
|
||||
import { WorkspaceService } from '@theia/workspace/lib/browser';
|
||||
import { URI } from '@theia/core/lib/common/uri';
|
||||
import { CODEX_API_KEY_PREF, CodexService, CodexBackendRequest } from '../common';
|
||||
import { API_KEY_PREF } from '@theia/ai-openai/lib/common/openai-preferences';
|
||||
|
||||
import type { CodexFrontendService, CodexClientImpl } from './codex-frontend-service';
|
||||
|
||||
disableJSDOM();
|
||||
|
||||
describe('CodexFrontendService', () => {
|
||||
let container: Container;
|
||||
let CodexFrontendServiceConstructor: interfaces.Newable<CodexFrontendService>;
|
||||
let CodexClientImplConstructor: interfaces.Newable<CodexClientImpl>;
|
||||
let mockPreferenceService: PreferenceService;
|
||||
let mockBackendService: CodexService;
|
||||
|
||||
before(async () => {
|
||||
disableJSDOM = enableJSDOM();
|
||||
|
||||
const serviceModule = await import('./codex-frontend-service');
|
||||
CodexFrontendServiceConstructor = serviceModule.CodexFrontendService;
|
||||
CodexClientImplConstructor = serviceModule.CodexClientImpl;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
container = new Container();
|
||||
|
||||
mockPreferenceService = {
|
||||
get: sinon.stub()
|
||||
} as unknown as PreferenceService;
|
||||
|
||||
mockBackendService = {
|
||||
send: sinon.stub<[CodexBackendRequest, string], Promise<void>>().resolves(),
|
||||
cancel: sinon.stub<[string], void>()
|
||||
};
|
||||
|
||||
const mockWorkspaceService = {
|
||||
roots: Promise.resolve([{ resource: new URI('file:///test/workspace') }])
|
||||
} as WorkspaceService;
|
||||
|
||||
container.bind(PreferenceService).toConstantValue(mockPreferenceService);
|
||||
container.bind(CodexService).toConstantValue(mockBackendService);
|
||||
container.bind(WorkspaceService).toConstantValue(mockWorkspaceService);
|
||||
container.bind(CodexClientImplConstructor).toSelf().inSingletonScope();
|
||||
container.bind(CodexFrontendServiceConstructor).toSelf();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
sinon.restore();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
disableJSDOM();
|
||||
});
|
||||
|
||||
describe('API Key Preference Hierarchy', () => {
|
||||
it('should prioritize Codex-specific API key over shared OpenAI key', async () => {
|
||||
(mockPreferenceService.get as sinon.SinonStub).withArgs(CODEX_API_KEY_PREF).returns('codex-key-123');
|
||||
(mockPreferenceService.get as sinon.SinonStub).withArgs(API_KEY_PREF).returns('openai-key-456');
|
||||
|
||||
const service = container.get<CodexFrontendService>(CodexFrontendServiceConstructor);
|
||||
await service.send({ prompt: 'test', sessionId: 'session-1' });
|
||||
|
||||
expect((mockBackendService.send as sinon.SinonStub).calledOnce).to.be.true;
|
||||
const backendRequest = (mockBackendService.send as sinon.SinonStub).firstCall.args[0];
|
||||
expect(backendRequest.apiKey).to.equal('codex-key-123');
|
||||
});
|
||||
|
||||
it('should fall back to shared OpenAI API key when Codex key not set', async () => {
|
||||
(mockPreferenceService.get as sinon.SinonStub).withArgs(CODEX_API_KEY_PREF).returns(undefined);
|
||||
(mockPreferenceService.get as sinon.SinonStub).withArgs(API_KEY_PREF).returns('openai-key-456');
|
||||
|
||||
const service = container.get<CodexFrontendService>(CodexFrontendServiceConstructor);
|
||||
await service.send({ prompt: 'test', sessionId: 'session-1' });
|
||||
|
||||
const backendRequest = (mockBackendService.send as sinon.SinonStub).firstCall.args[0];
|
||||
expect(backendRequest.apiKey).to.equal('openai-key-456');
|
||||
});
|
||||
|
||||
it('should return undefined when neither key is set', async () => {
|
||||
(mockPreferenceService.get as sinon.SinonStub).withArgs(CODEX_API_KEY_PREF).returns(undefined);
|
||||
(mockPreferenceService.get as sinon.SinonStub).withArgs(API_KEY_PREF).returns(undefined);
|
||||
|
||||
const service = container.get<CodexFrontendService>(CodexFrontendServiceConstructor);
|
||||
await service.send({ prompt: 'test', sessionId: 'session-1' });
|
||||
|
||||
const backendRequest = (mockBackendService.send as sinon.SinonStub).firstCall.args[0];
|
||||
expect(backendRequest.apiKey).to.be.undefined;
|
||||
});
|
||||
|
||||
it('should treat empty Codex key as not set', async () => {
|
||||
(mockPreferenceService.get as sinon.SinonStub).withArgs(CODEX_API_KEY_PREF).returns('');
|
||||
(mockPreferenceService.get as sinon.SinonStub).withArgs(API_KEY_PREF).returns('openai-key-456');
|
||||
|
||||
const service = container.get<CodexFrontendService>(CodexFrontendServiceConstructor);
|
||||
await service.send({ prompt: 'test', sessionId: 'session-1' });
|
||||
|
||||
const backendRequest = (mockBackendService.send as sinon.SinonStub).firstCall.args[0];
|
||||
expect(backendRequest.apiKey).to.equal('openai-key-456');
|
||||
});
|
||||
|
||||
it('should treat whitespace-only Codex key as not set', async () => {
|
||||
(mockPreferenceService.get as sinon.SinonStub).withArgs(CODEX_API_KEY_PREF).returns(' ');
|
||||
(mockPreferenceService.get as sinon.SinonStub).withArgs(API_KEY_PREF).returns('openai-key-456');
|
||||
|
||||
const service = container.get<CodexFrontendService>(CodexFrontendServiceConstructor);
|
||||
await service.send({ prompt: 'test', sessionId: 'session-1' });
|
||||
|
||||
const backendRequest = (mockBackendService.send as sinon.SinonStub).firstCall.args[0];
|
||||
expect(backendRequest.apiKey).to.equal('openai-key-456');
|
||||
});
|
||||
|
||||
it('should treat empty OpenAI key as not set', async () => {
|
||||
(mockPreferenceService.get as sinon.SinonStub).withArgs(CODEX_API_KEY_PREF).returns(undefined);
|
||||
(mockPreferenceService.get as sinon.SinonStub).withArgs(API_KEY_PREF).returns('');
|
||||
|
||||
const service = container.get<CodexFrontendService>(CodexFrontendServiceConstructor);
|
||||
await service.send({ prompt: 'test', sessionId: 'session-1' });
|
||||
|
||||
const backendRequest = (mockBackendService.send as sinon.SinonStub).firstCall.args[0];
|
||||
expect(backendRequest.apiKey).to.be.undefined;
|
||||
});
|
||||
|
||||
it('should treat whitespace-only OpenAI key as not set', async () => {
|
||||
(mockPreferenceService.get as sinon.SinonStub).withArgs(CODEX_API_KEY_PREF).returns(undefined);
|
||||
(mockPreferenceService.get as sinon.SinonStub).withArgs(API_KEY_PREF).returns(' ');
|
||||
|
||||
const service = container.get<CodexFrontendService>(CodexFrontendServiceConstructor);
|
||||
await service.send({ prompt: 'test', sessionId: 'session-1' });
|
||||
|
||||
const backendRequest = (mockBackendService.send as sinon.SinonStub).firstCall.args[0];
|
||||
expect(backendRequest.apiKey).to.be.undefined;
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sandbox Mode Configuration', () => {
|
||||
beforeEach(() => {
|
||||
(mockPreferenceService.get as sinon.SinonStub).withArgs(CODEX_API_KEY_PREF).returns('test-key');
|
||||
});
|
||||
|
||||
it('should default to workspace-write when no sandboxMode is provided in request', async () => {
|
||||
const service = container.get<CodexFrontendService>(CodexFrontendServiceConstructor);
|
||||
await service.send({ prompt: 'test', sessionId: 'session-1' });
|
||||
|
||||
const backendRequest = (mockBackendService.send as sinon.SinonStub).firstCall.args[0];
|
||||
expect(backendRequest.options?.sandboxMode).to.equal('workspace-write');
|
||||
});
|
||||
|
||||
it('should use sandboxMode from request when provided', async () => {
|
||||
const service = container.get<CodexFrontendService>(CodexFrontendServiceConstructor);
|
||||
await service.send({ prompt: 'test', sessionId: 'session-1', sandboxMode: 'read-only' });
|
||||
|
||||
const backendRequest = (mockBackendService.send as sinon.SinonStub).firstCall.args[0];
|
||||
expect(backendRequest.options?.sandboxMode).to.equal('read-only');
|
||||
});
|
||||
|
||||
it('should use danger-full-access when provided in request', async () => {
|
||||
const service = container.get<CodexFrontendService>(CodexFrontendServiceConstructor);
|
||||
await service.send({ prompt: 'test', sessionId: 'session-1', sandboxMode: 'danger-full-access' });
|
||||
|
||||
const backendRequest = (mockBackendService.send as sinon.SinonStub).firstCall.args[0];
|
||||
expect(backendRequest.options?.sandboxMode).to.equal('danger-full-access');
|
||||
});
|
||||
|
||||
it('should default to workspace-write when request sandboxMode is undefined', async () => {
|
||||
const service = container.get<CodexFrontendService>(CodexFrontendServiceConstructor);
|
||||
await service.send({ prompt: 'test', sessionId: 'session-1', sandboxMode: undefined });
|
||||
|
||||
const backendRequest = (mockBackendService.send as sinon.SinonStub).firstCall.args[0];
|
||||
expect(backendRequest.options?.sandboxMode).to.equal('workspace-write');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Request Building', () => {
|
||||
beforeEach(() => {
|
||||
(mockPreferenceService.get as sinon.SinonStub).withArgs(CODEX_API_KEY_PREF).returns('test-key');
|
||||
});
|
||||
|
||||
it('should include workspace root in request', async () => {
|
||||
const service = container.get<CodexFrontendService>(CodexFrontendServiceConstructor);
|
||||
await service.send({ prompt: 'test prompt', sessionId: 'session-1' });
|
||||
|
||||
const backendRequest = (mockBackendService.send as sinon.SinonStub).firstCall.args[0];
|
||||
const expectedPath = path.join('test', 'workspace');
|
||||
expect(backendRequest.options?.workingDirectory).to.include(expectedPath);
|
||||
});
|
||||
|
||||
it('should pass prompt to backend', async () => {
|
||||
const service = container.get<CodexFrontendService>(CodexFrontendServiceConstructor);
|
||||
await service.send({ prompt: 'my test prompt', sessionId: 'session-1' });
|
||||
|
||||
const backendRequest = (mockBackendService.send as sinon.SinonStub).firstCall.args[0];
|
||||
expect(backendRequest.prompt).to.equal('my test prompt');
|
||||
});
|
||||
|
||||
it('should pass sessionId to backend', async () => {
|
||||
const service = container.get<CodexFrontendService>(CodexFrontendServiceConstructor);
|
||||
await service.send({ prompt: 'test', sessionId: 'my-session-123' });
|
||||
|
||||
const backendRequest = (mockBackendService.send as sinon.SinonStub).firstCall.args[0];
|
||||
expect(backendRequest.sessionId).to.equal('my-session-123');
|
||||
});
|
||||
|
||||
it('should merge custom options with defaults', async () => {
|
||||
const service = container.get<CodexFrontendService>(CodexFrontendServiceConstructor);
|
||||
await service.send({
|
||||
prompt: 'test',
|
||||
sessionId: 'session-1',
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
options: { customOption: 'value' } as any // Testing dynamic options extension
|
||||
});
|
||||
|
||||
const backendRequest = (mockBackendService.send as sinon.SinonStub).firstCall.args[0];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect((backendRequest.options as any)?.customOption).to.equal('value'); // Accessing dynamic test property
|
||||
expect(backendRequest.options?.sandboxMode).to.equal('workspace-write');
|
||||
});
|
||||
});
|
||||
});
|
||||
221
packages/ai-codex/src/browser/codex-frontend-service.ts
Normal file
221
packages/ai-codex/src/browser/codex-frontend-service.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
// *****************************************************************************
|
||||
// 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 { inject, injectable } from '@theia/core/shared/inversify';
|
||||
import { CancellationToken, generateUuid, PreferenceService } from '@theia/core';
|
||||
import { FileUri } from '@theia/core/lib/common/file-uri';
|
||||
import { WorkspaceService } from '@theia/workspace/lib/browser';
|
||||
import { API_KEY_PREF } from '@theia/ai-openai/lib/common/openai-preferences';
|
||||
import type { ThreadEvent } from '@openai/codex-sdk';
|
||||
import {
|
||||
CodexClient,
|
||||
CodexRequest,
|
||||
CodexService,
|
||||
CodexBackendRequest,
|
||||
CODEX_API_KEY_PREF
|
||||
} from '../common';
|
||||
|
||||
@injectable()
|
||||
export class CodexClientImpl implements CodexClient {
|
||||
protected tokenHandlers = new Map<string, (token?: ThreadEvent) => void>();
|
||||
protected errorHandlers = new Map<string, (error: Error) => void>();
|
||||
|
||||
/**
|
||||
* `undefined` token signals end of stream per RPC protocol.
|
||||
*/
|
||||
sendToken(streamId: string, token?: ThreadEvent): void {
|
||||
const handler = this.tokenHandlers.get(streamId);
|
||||
if (handler) {
|
||||
handler(token);
|
||||
}
|
||||
}
|
||||
|
||||
sendError(streamId: string, error: Error): void {
|
||||
const handler = this.errorHandlers.get(streamId);
|
||||
if (handler) {
|
||||
handler(error);
|
||||
}
|
||||
}
|
||||
|
||||
registerTokenHandler(streamId: string, handler: (token?: ThreadEvent) => void): void {
|
||||
this.tokenHandlers.set(streamId, handler);
|
||||
}
|
||||
|
||||
registerErrorHandler(streamId: string, handler: (error: Error) => void): void {
|
||||
this.errorHandlers.set(streamId, handler);
|
||||
}
|
||||
|
||||
unregisterHandlers(streamId: string): void {
|
||||
this.tokenHandlers.delete(streamId);
|
||||
this.errorHandlers.delete(streamId);
|
||||
}
|
||||
}
|
||||
|
||||
interface StreamState {
|
||||
id: string;
|
||||
tokens: (ThreadEvent | undefined)[];
|
||||
isComplete: boolean;
|
||||
hasError: boolean;
|
||||
error?: Error;
|
||||
pendingResolve?: () => void;
|
||||
pendingReject?: (error: Error) => void;
|
||||
}
|
||||
|
||||
@injectable()
|
||||
export class CodexFrontendService {
|
||||
|
||||
@inject(CodexService)
|
||||
protected readonly backendService: CodexService;
|
||||
|
||||
@inject(CodexClientImpl)
|
||||
protected readonly client: CodexClientImpl;
|
||||
|
||||
@inject(PreferenceService)
|
||||
protected readonly preferenceService: PreferenceService;
|
||||
|
||||
@inject(WorkspaceService)
|
||||
protected readonly workspaceService: WorkspaceService;
|
||||
|
||||
protected streams = new Map<string, StreamState>();
|
||||
|
||||
async send(request: CodexRequest, cancellationToken?: CancellationToken): Promise<AsyncIterable<ThreadEvent>> {
|
||||
const streamState: StreamState = {
|
||||
id: this.generateStreamId(),
|
||||
tokens: [],
|
||||
isComplete: false,
|
||||
hasError: false
|
||||
};
|
||||
this.streams.set(streamState.id, streamState);
|
||||
this.setupStreamHandlers(streamState);
|
||||
|
||||
cancellationToken?.onCancellationRequested(() => {
|
||||
this.backendService.cancel(streamState.id);
|
||||
this.cleanup(streamState.id);
|
||||
});
|
||||
|
||||
const apiKey = this.getApiKey();
|
||||
const sandboxMode = request.sandboxMode ?? 'workspace-write';
|
||||
const workingDirectory = await this.getWorkspaceRoot();
|
||||
|
||||
const backendRequest: CodexBackendRequest = {
|
||||
prompt: request.prompt,
|
||||
options: {
|
||||
workingDirectory,
|
||||
...request.options,
|
||||
sandboxMode
|
||||
},
|
||||
apiKey,
|
||||
sessionId: request.sessionId
|
||||
};
|
||||
|
||||
await this.backendService.send(backendRequest, streamState.id);
|
||||
|
||||
return this.createAsyncIterable(streamState);
|
||||
}
|
||||
|
||||
protected generateStreamId(): string {
|
||||
return generateUuid();
|
||||
}
|
||||
|
||||
protected setupStreamHandlers(streamState: StreamState): void {
|
||||
this.client.registerTokenHandler(streamState.id, (token?: ThreadEvent) => {
|
||||
if (token === undefined) {
|
||||
streamState.isComplete = true;
|
||||
} else {
|
||||
streamState.tokens.push(token);
|
||||
}
|
||||
|
||||
if (streamState.pendingResolve) {
|
||||
streamState.pendingResolve();
|
||||
streamState.pendingResolve = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
this.client.registerErrorHandler(streamState.id, (error: Error) => {
|
||||
streamState.hasError = true;
|
||||
streamState.error = error;
|
||||
|
||||
if (streamState.pendingReject) {
|
||||
streamState.pendingReject(error);
|
||||
streamState.pendingReject = undefined;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected async *createAsyncIterable(streamState: StreamState): AsyncIterable<ThreadEvent> {
|
||||
let currentIndex = 0;
|
||||
|
||||
while (true) {
|
||||
if (currentIndex < streamState.tokens.length) {
|
||||
const token = streamState.tokens[currentIndex];
|
||||
currentIndex++;
|
||||
if (token !== undefined) {
|
||||
yield token;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (streamState.isComplete) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (streamState.hasError && streamState.error) {
|
||||
this.cleanup(streamState.id);
|
||||
throw streamState.error;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
streamState.pendingResolve = resolve;
|
||||
streamState.pendingReject = reject;
|
||||
});
|
||||
}
|
||||
|
||||
this.cleanup(streamState.id);
|
||||
}
|
||||
|
||||
protected cleanup(streamId: string): void {
|
||||
this.client.unregisterHandlers(streamId);
|
||||
this.streams.delete(streamId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback hierarchy:
|
||||
* 1. Codex-specific API key (highest priority)
|
||||
* 2. Shared OpenAI API key
|
||||
* 3. undefined (backend will check OPENAI_API_KEY env var)
|
||||
*/
|
||||
protected getApiKey(): string | undefined {
|
||||
const codexKey = this.preferenceService.get<string>(CODEX_API_KEY_PREF);
|
||||
if (codexKey && codexKey.trim()) {
|
||||
return codexKey;
|
||||
}
|
||||
|
||||
const openaiKey = this.preferenceService.get<string>(API_KEY_PREF);
|
||||
if (openaiKey && openaiKey.trim()) {
|
||||
return openaiKey;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
protected async getWorkspaceRoot(): Promise<string | undefined> {
|
||||
const roots = await this.workspaceService.roots;
|
||||
if (roots && roots.length > 0) {
|
||||
return FileUri.fsPath(roots[0].resource.toString());
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
42
packages/ai-codex/src/browser/codex-tool-call-content.ts
Normal file
42
packages/ai-codex/src/browser/codex-tool-call-content.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
// *****************************************************************************
|
||||
// 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 { ToolCallChatResponseContentImpl } from '@theia/ai-chat/lib/common';
|
||||
import { ToolCallResult } from '@theia/ai-core';
|
||||
|
||||
export class CodexToolCallChatResponseContent extends ToolCallChatResponseContentImpl {
|
||||
static readonly type = 'codex-tool-call';
|
||||
|
||||
constructor(id?: string, name?: string, arg_string?: string, finished?: boolean, result?: ToolCallResult) {
|
||||
super(id, name, arg_string, finished, result);
|
||||
}
|
||||
|
||||
static is(content: unknown): content is CodexToolCallChatResponseContent {
|
||||
return content instanceof CodexToolCallChatResponseContent;
|
||||
}
|
||||
|
||||
update(args?: string, finished?: boolean, result?: ToolCallResult): void {
|
||||
if (args !== undefined) {
|
||||
this._arguments = args;
|
||||
}
|
||||
if (finished !== undefined) {
|
||||
this._finished = finished;
|
||||
}
|
||||
if (result !== undefined) {
|
||||
this._result = result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// *****************************************************************************
|
||||
// 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 { codicon } from '@theia/core/lib/browser';
|
||||
import * as React from '@theia/core/shared/react';
|
||||
import { ReactNode } from '@theia/core/shared/react';
|
||||
|
||||
interface CollapsibleToolRendererProps {
|
||||
compactHeader: ReactNode;
|
||||
expandedContent?: ReactNode;
|
||||
onHeaderClick?: () => void;
|
||||
headerStyle?: React.CSSProperties;
|
||||
defaultExpanded?: boolean;
|
||||
}
|
||||
|
||||
export const CollapsibleToolRenderer: React.FC<CollapsibleToolRendererProps> = ({
|
||||
compactHeader,
|
||||
expandedContent,
|
||||
onHeaderClick,
|
||||
headerStyle,
|
||||
defaultExpanded = false
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = React.useState(defaultExpanded);
|
||||
|
||||
const hasExpandableContent = expandedContent !== undefined;
|
||||
|
||||
const handleHeaderClick = (event: React.MouseEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.closest('.clickable-element')) {
|
||||
onHeaderClick?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasExpandableContent) {
|
||||
setIsExpanded(!isExpanded);
|
||||
}
|
||||
onHeaderClick?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="codex-tool container">
|
||||
<div
|
||||
className={`codex-tool header${hasExpandableContent ? ' expandable' : ''}`}
|
||||
onClick={handleHeaderClick}
|
||||
style={{
|
||||
cursor: hasExpandableContent || onHeaderClick ? 'pointer' : 'default',
|
||||
...headerStyle
|
||||
}}
|
||||
>
|
||||
{hasExpandableContent && (
|
||||
<span className={`${codicon(isExpanded ? 'chevron-down' : 'chevron-right')} codex-tool expand-icon`} />
|
||||
)}
|
||||
{compactHeader}
|
||||
</div>
|
||||
{hasExpandableContent && isExpanded && (
|
||||
<div className="codex-tool expanded-content">
|
||||
{expandedContent}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
// *****************************************************************************
|
||||
// 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 { ChatResponseContent } from '@theia/ai-chat';
|
||||
import { ChatResponsePartRenderer } from '@theia/ai-chat-ui/lib/browser/chat-response-part-renderer';
|
||||
import { codicon } from '@theia/core/lib/browser';
|
||||
import { injectable } from '@theia/core/shared/inversify';
|
||||
import * as React from '@theia/core/shared/react';
|
||||
import { ReactNode } from '@theia/core/shared/react';
|
||||
import type { CommandExecutionItem } from '@openai/codex-sdk';
|
||||
import { CodexToolCallChatResponseContent } from '../codex-tool-call-content';
|
||||
import { nls } from '@theia/core';
|
||||
|
||||
@injectable()
|
||||
export class CommandExecutionRenderer implements ChatResponsePartRenderer<CodexToolCallChatResponseContent> {
|
||||
canHandle(response: ChatResponseContent): number {
|
||||
return response.kind === 'toolCall' &&
|
||||
(response as CodexToolCallChatResponseContent).name === 'command_execution'
|
||||
? 100
|
||||
: 0;
|
||||
}
|
||||
|
||||
render(content: CodexToolCallChatResponseContent): ReactNode {
|
||||
let item: CommandExecutionItem | undefined;
|
||||
|
||||
if (content.result) {
|
||||
try {
|
||||
item = typeof content.result === 'string'
|
||||
? JSON.parse(content.result)
|
||||
: content.result as CommandExecutionItem;
|
||||
} catch (error) {
|
||||
console.error('Failed to parse command execution result:', error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (!item) {
|
||||
const args = content.arguments ? JSON.parse(content.arguments) : {};
|
||||
return <CommandExecutionInProgressComponent command={args.command || 'executing...'} />;
|
||||
}
|
||||
|
||||
return <CommandExecutionComponent item={item} />;
|
||||
}
|
||||
}
|
||||
|
||||
const CommandExecutionInProgressComponent: React.FC<{
|
||||
command: string;
|
||||
}> = ({ command }) => (
|
||||
<div className="codex-command-execution">
|
||||
<div className="codex-tool-header">
|
||||
<span className={`${codicon('loading')} codex-tool-icon codex-loading`}></span>
|
||||
<span className="codex-tool-name">{nls.localizeByDefault('Terminal')}</span>
|
||||
<code className="codex-command-line">{command}</code>
|
||||
<span className="codex-exit-code in-progress">
|
||||
{nls.localize('theia/ai/codex/running', 'Running...')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const CommandExecutionComponent: React.FC<{
|
||||
item: CommandExecutionItem;
|
||||
}> = ({ item }) => {
|
||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||
const success = item.exit_code === 0;
|
||||
|
||||
const hasOutput = item.aggregated_output && item.aggregated_output.trim().length > 0;
|
||||
|
||||
return (
|
||||
<div className="codex-command-execution">
|
||||
<div
|
||||
className={`codex-tool-header${hasOutput ? ' expandable' : ''}`}
|
||||
onClick={() => hasOutput && setIsExpanded(!isExpanded)}
|
||||
style={{ cursor: hasOutput ? 'pointer' : 'default' }}
|
||||
>
|
||||
{hasOutput && (
|
||||
<span className={`${codicon(isExpanded ? 'chevron-down' : 'chevron-right')} codex-expand-icon`} />
|
||||
)}
|
||||
<span className={`${codicon('terminal')} codex-tool-icon`}></span>
|
||||
<span className="codex-tool-name">{nls.localizeByDefault('Terminal')}</span>
|
||||
<code className="codex-command-line">{item.command}</code>
|
||||
<span className={`codex-exit-code ${success ? 'success' : 'error'}`}>
|
||||
{nls.localize('theia/ai/codex/exitCode', 'Exit code: {0}', item.exit_code)}
|
||||
</span>
|
||||
</div>
|
||||
{hasOutput && isExpanded && (
|
||||
<pre className="codex-command-output">{item.aggregated_output}</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
151
packages/ai-codex/src/browser/renderers/todo-list-renderer.tsx
Normal file
151
packages/ai-codex/src/browser/renderers/todo-list-renderer.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
// *****************************************************************************
|
||||
// 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 { ChatResponsePartRenderer } from '@theia/ai-chat-ui/lib/browser/chat-response-part-renderer';
|
||||
import { ResponseNode } from '@theia/ai-chat-ui/lib/browser/chat-tree-view';
|
||||
import { ChatResponseContent } from '@theia/ai-chat/lib/common';
|
||||
import { codicon } from '@theia/core/lib/browser';
|
||||
import { nls } from '@theia/core';
|
||||
import { injectable } from '@theia/core/shared/inversify';
|
||||
import * as React from '@theia/core/shared/react';
|
||||
import { ReactNode } from '@theia/core/shared/react';
|
||||
import type { TodoListItem } from '@openai/codex-sdk';
|
||||
import { CodexToolCallChatResponseContent } from '../codex-tool-call-content';
|
||||
import { CollapsibleToolRenderer } from './collapsible-tool-renderer';
|
||||
|
||||
@injectable()
|
||||
export class TodoListRenderer implements ChatResponsePartRenderer<CodexToolCallChatResponseContent> {
|
||||
|
||||
canHandle(response: ChatResponseContent): number {
|
||||
return response.kind === 'toolCall' &&
|
||||
(response as CodexToolCallChatResponseContent).name === 'todo_list'
|
||||
? 15
|
||||
: 0;
|
||||
}
|
||||
|
||||
render(content: CodexToolCallChatResponseContent, parentNode: ResponseNode): ReactNode {
|
||||
let item: TodoListItem | undefined;
|
||||
|
||||
if (content.result) {
|
||||
try {
|
||||
item = typeof content.result === 'string'
|
||||
? JSON.parse(content.result)
|
||||
: content.result as TodoListItem;
|
||||
} catch (error) {
|
||||
console.error('[TodoListRenderer] Failed to parse todo_list result:', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (!item && content.arguments) {
|
||||
try {
|
||||
const args = JSON.parse(content.arguments);
|
||||
if (args.items) {
|
||||
item = {
|
||||
id: args.id || 'unknown',
|
||||
type: 'todo_list',
|
||||
items: args.items
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[TodoListRenderer] Failed to parse todo_list arguments:', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (!item) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return <TodoListComponent item={item} />;
|
||||
}
|
||||
}
|
||||
|
||||
const TodoListComponent: React.FC<{ item: TodoListItem }> = ({ item }) => {
|
||||
const items = item.items || [];
|
||||
const completedCount = items.filter(todo => todo.completed).length;
|
||||
const totalCount = items.length;
|
||||
|
||||
if (totalCount === 0) {
|
||||
// Show empty state
|
||||
return (
|
||||
<div className="codex-tool container">
|
||||
<div className="codex-tool header">
|
||||
<div className="codex-tool header-left">
|
||||
<span className="codex-tool title">{nls.localize('theia/ai/codex/todoList', 'Todo List')}</span>
|
||||
<span className={`${codicon('checklist')} codex-tool icon`} />
|
||||
<span className="codex-tool progress-text">
|
||||
{nls.localizeByDefault('Loading...')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="codex-tool header-right">
|
||||
<span className="codex-tool badge">
|
||||
{nls.localize('theia/ai/codex/noItems', 'No items')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const compactHeader = (
|
||||
<>
|
||||
<div className="codex-tool header-left">
|
||||
<span className="codex-tool title">{nls.localize('theia/ai/codex/todoList', 'Todo List')}</span>
|
||||
<span className={`${codicon('checklist')} codex-tool icon`} />
|
||||
<span className="codex-tool progress-text">
|
||||
{nls.localize('theia/ai/codex/completedCount', '{0}/{1} completed', completedCount, totalCount)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="codex-tool header-right">
|
||||
<span className="codex-tool badge">
|
||||
{totalCount === 1
|
||||
? nls.localize('theia/ai/codex/oneItem', '1 item')
|
||||
: nls.localize('theia/ai/codex/itemCount', '{0} items', totalCount)}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
const expandedContent = (
|
||||
<div className="codex-tool details">
|
||||
<div className="codex-tool todo-list-items">
|
||||
{items.map((todo, index) => {
|
||||
const statusIcon = todo.completed
|
||||
? <span className={`${codicon('check')} codex-tool todo-status-icon completed`} />
|
||||
: <span className={`${codicon('circle-outline')} codex-tool todo-status-icon pending`} />;
|
||||
|
||||
return (
|
||||
<div key={index} className={`codex-tool todo-item ${todo.completed ? 'status-completed' : 'status-pending'}`}>
|
||||
<div className="codex-tool todo-item-main">
|
||||
<div className="codex-tool todo-item-status">{statusIcon}</div>
|
||||
<div className="codex-tool todo-item-content">
|
||||
<span className="codex-tool todo-item-text">{todo.text}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<CollapsibleToolRenderer
|
||||
compactHeader={compactHeader}
|
||||
expandedContent={expandedContent}
|
||||
defaultExpanded={true}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
// *****************************************************************************
|
||||
// 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 { ChatResponsePartRenderer } from '@theia/ai-chat-ui/lib/browser/chat-response-part-renderer';
|
||||
import { ChatResponseContent } from '@theia/ai-chat/lib/common';
|
||||
import { codicon } from '@theia/core/lib/browser';
|
||||
import { nls } from '@theia/core';
|
||||
import { injectable } from '@theia/core/shared/inversify';
|
||||
import * as React from '@theia/core/shared/react';
|
||||
import { ReactNode } from '@theia/core/shared/react';
|
||||
import type { WebSearchItem } from '@openai/codex-sdk';
|
||||
import { CodexToolCallChatResponseContent } from '../codex-tool-call-content';
|
||||
|
||||
@injectable()
|
||||
export class WebSearchRenderer implements ChatResponsePartRenderer<CodexToolCallChatResponseContent> {
|
||||
|
||||
canHandle(response: ChatResponseContent): number {
|
||||
return response.kind === 'toolCall' &&
|
||||
(response as CodexToolCallChatResponseContent).name === 'web_search'
|
||||
? 15
|
||||
: 0;
|
||||
}
|
||||
|
||||
render(content: CodexToolCallChatResponseContent): ReactNode {
|
||||
let item: WebSearchItem | undefined;
|
||||
|
||||
if (content.result) {
|
||||
try {
|
||||
item = typeof content.result === 'string'
|
||||
? JSON.parse(content.result)
|
||||
: content.result as WebSearchItem;
|
||||
} catch (error) {
|
||||
console.error('Failed to parse web_search result:', error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (!item) {
|
||||
const args = content.arguments ? JSON.parse(content.arguments) : {};
|
||||
return <WebSearchInProgressComponent query={args.query || ''} />;
|
||||
}
|
||||
|
||||
return <WebSearchCompletedComponent item={item} />;
|
||||
}
|
||||
}
|
||||
|
||||
const WebSearchInProgressComponent: React.FC<{ query: string }> = ({ query }) => (
|
||||
<div className="codex-tool container">
|
||||
<div className="codex-tool header">
|
||||
<div className="codex-tool header-left">
|
||||
<span className="codex-tool title">{nls.localize('theia/ai/codex/searching', 'Searching')}</span>
|
||||
<span className={`${codicon('loading')} codex-tool icon theia-animation-spin`} />
|
||||
<span className="codex-tool command">{query}</span>
|
||||
</div>
|
||||
<div className="codex-tool header-right">
|
||||
<span className="codex-tool badge">{nls.localize('theia/ai/codex/webSearch', 'Web Search')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const WebSearchCompletedComponent: React.FC<{ item: WebSearchItem }> = ({ item }) => (
|
||||
<div className="codex-tool container">
|
||||
<div className="codex-tool header">
|
||||
<div className="codex-tool header-left">
|
||||
<span className="codex-tool title">{nls.localize('theia/ai/codex/searched', 'Searched')}</span>
|
||||
<span className={`${codicon('globe')} codex-tool icon`} />
|
||||
<span className="codex-tool command">{item.query}</span>
|
||||
</div>
|
||||
<div className="codex-tool header-right">
|
||||
<span className="codex-tool badge">{nls.localize('theia/ai/codex/webSearch', 'Web Search')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
0
packages/ai-codex/src/browser/style/.gitkeep
Normal file
0
packages/ai-codex/src/browser/style/.gitkeep
Normal file
443
packages/ai-codex/src/browser/style/codex-tool-renderers.css
Normal file
443
packages/ai-codex/src/browser/style/codex-tool-renderers.css
Normal file
@@ -0,0 +1,443 @@
|
||||
/* *****************************************************************************
|
||||
* 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
|
||||
* *****************************************************************************/
|
||||
|
||||
/* Command Execution Tool */
|
||||
.codex-command-execution {
|
||||
border: var(--theia-border-width) solid var(--theia-sideBarSectionHeader-border);
|
||||
border-radius: var(--theia-ui-padding);
|
||||
margin: var(--theia-ui-padding) 0;
|
||||
background-color: var(--theia-sideBar-background);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.codex-command-execution:hover {
|
||||
background-color: var(--theia-list-hoverBackground);
|
||||
}
|
||||
|
||||
.codex-tool-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--theia-ui-padding);
|
||||
padding: 6px;
|
||||
background-color: var(--theia-editorGroupHeader-tabsBackground);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.codex-tool-header.expandable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.codex-expand-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.codex-tool-icon {
|
||||
color: var(--theia-charts-blue);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.codex-tool-name {
|
||||
font-weight: 600;
|
||||
color: var(--theia-foreground);
|
||||
font-size: var(--theia-ui-font-size1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.codex-command-line {
|
||||
color: var(--theia-foreground);
|
||||
font-family: var(--monaco-monospace-font);
|
||||
font-size: var(--theia-ui-font-size1);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.codex-exit-code {
|
||||
font-size: var(--theia-ui-font-size0);
|
||||
font-weight: 500;
|
||||
padding: 2px 6px;
|
||||
border-radius: calc(var(--theia-ui-padding) / 3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.codex-exit-code.success {
|
||||
background-color: var(--theia-charts-green);
|
||||
color: var(--theia-button-foreground);
|
||||
}
|
||||
|
||||
.codex-exit-code.error {
|
||||
background-color: var(--theia-charts-red);
|
||||
color: var(--theia-button-foreground);
|
||||
}
|
||||
|
||||
.codex-exit-code.in-progress {
|
||||
background-color: var(--theia-charts-orange);
|
||||
color: var(--theia-button-foreground);
|
||||
}
|
||||
|
||||
.codex-tool-icon.codex-loading {
|
||||
animation: codex-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes codex-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.codex-command-output {
|
||||
margin: 0;
|
||||
padding: var(--theia-ui-padding);
|
||||
background-color: var(--theia-terminal-background);
|
||||
color: var(--theia-terminal-foreground);
|
||||
border-top: var(--theia-border-width) solid var(--theia-sideBarSectionHeader-border);
|
||||
overflow-x: auto;
|
||||
font-family: var(--monaco-monospace-font);
|
||||
font-size: var(--theia-ui-font-size0);
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* Base container and structure for collapsible tools */
|
||||
.codex-tool.container {
|
||||
border: var(--theia-border-width) solid var(--theia-sideBarSectionHeader-border);
|
||||
border-radius: var(--theia-ui-padding);
|
||||
margin: var(--theia-ui-padding) 0;
|
||||
background-color: var(--theia-sideBar-background);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.codex-tool.container:hover {
|
||||
background-color: var(--theia-list-hoverBackground);
|
||||
}
|
||||
|
||||
.codex-tool.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px;
|
||||
background-color: var(--theia-editorGroupHeader-tabsBackground);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.codex-tool.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--theia-ui-padding);
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.codex-tool.header-right {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.codex-tool.icon {
|
||||
color: var(--theia-charts-blue);
|
||||
}
|
||||
|
||||
.codex-tool.title {
|
||||
font-weight: 600;
|
||||
color: var(--theia-foreground);
|
||||
font-size: var(--theia-ui-font-size1);
|
||||
}
|
||||
|
||||
.codex-tool.command {
|
||||
color: var(--theia-foreground);
|
||||
font-size: var(--theia-ui-font-size1);
|
||||
font-family: var(--theia-ui-font-family-mono);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.codex-tool.description {
|
||||
color: var(--theia-descriptionForeground);
|
||||
font-size: var(--theia-ui-font-size1);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.codex-tool.badge {
|
||||
font-size: var(--theia-ui-font-size0);
|
||||
font-weight: 500;
|
||||
padding: 2px 6px;
|
||||
background-color: var(--theia-badge-background);
|
||||
color: var(--theia-badge-foreground);
|
||||
border-radius: calc(var(--theia-ui-padding) / 3);
|
||||
font-family: var(--theia-ui-font-family-mono);
|
||||
}
|
||||
|
||||
.codex-tool.error {
|
||||
padding: var(--theia-ui-padding);
|
||||
color: var(--theia-errorForeground);
|
||||
background-color: var(--theia-errorBackground);
|
||||
border-radius: var(--theia-ui-padding);
|
||||
margin: var(--theia-ui-padding) 0;
|
||||
border: var(--theia-border-width) solid var(--theia-errorBorder);
|
||||
}
|
||||
|
||||
/* Collapsible section styles */
|
||||
.codex-tool.expand-icon {
|
||||
margin-right: calc(var(--theia-ui-padding) / 2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.codex-tool.expanded-content {
|
||||
padding: var(--theia-ui-padding);
|
||||
}
|
||||
|
||||
/* Detail section styles */
|
||||
.codex-tool.details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(var(--theia-ui-padding) / 2);
|
||||
}
|
||||
|
||||
.codex-tool.detail-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: calc(var(--theia-ui-padding) / 2);
|
||||
margin-bottom: calc(var(--theia-ui-padding) / 3);
|
||||
}
|
||||
|
||||
.codex-tool.detail-label {
|
||||
color: var(--theia-descriptionForeground);
|
||||
font-size: var(--theia-ui-font-size1);
|
||||
font-weight: 500;
|
||||
min-width: 80px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.codex-tool.detail-label::after {
|
||||
content: ':';
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.codex-tool.detail-value {
|
||||
color: var(--theia-foreground);
|
||||
font-size: var(--theia-ui-font-size1);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* TodoWrite Renderer Styles */
|
||||
.codex-tool.todo-list-container {
|
||||
border: var(--theia-border-width) solid var(--theia-sideBarSectionHeader-border);
|
||||
border-radius: var(--theia-ui-padding);
|
||||
margin: var(--theia-ui-padding) 0;
|
||||
background-color: var(--theia-sideBar-background);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.codex-tool.todo-list-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--theia-ui-padding);
|
||||
padding: 6px;
|
||||
background-color: var(--theia-editorGroupHeader-tabsBackground);
|
||||
border-bottom: var(--theia-border-width) solid var(--theia-sideBarSectionHeader-border);
|
||||
}
|
||||
|
||||
.codex-tool.todo-list-icon {
|
||||
color: var(--theia-button-background);
|
||||
font-size: var(--theia-icon-size);
|
||||
}
|
||||
|
||||
.codex-tool.todo-list-title {
|
||||
font-weight: 600;
|
||||
color: var(--theia-foreground);
|
||||
font-size: var(--theia-ui-font-size1);
|
||||
}
|
||||
|
||||
.codex-tool.todo-list-empty {
|
||||
padding: var(--theia-ui-padding);
|
||||
color: var(--theia-descriptionForeground);
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.codex-tool.todo-list-items {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.codex-tool.todo-item {
|
||||
border-bottom: var(--theia-border-width) solid var(--theia-sideBarSectionHeader-border);
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.codex-tool.todo-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.codex-tool.todo-item:hover {
|
||||
background-color: var(--theia-toolbar-hoverBackground);
|
||||
}
|
||||
|
||||
.codex-tool.todo-item-main {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: calc(var(--theia-ui-padding) * 2 / 3);
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.codex-tool.todo-item-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: var(--theia-icon-size);
|
||||
height: var(--theia-icon-size);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.codex-tool.todo-status-icon {
|
||||
font-size: var(--theia-icon-size);
|
||||
}
|
||||
|
||||
.codex-tool.todo-status-icon.completed {
|
||||
color: var(--theia-charts-green);
|
||||
}
|
||||
|
||||
.codex-tool.todo-status-icon.in-progress {
|
||||
color: var(--theia-progressBar-background);
|
||||
}
|
||||
|
||||
.codex-tool.todo-status-icon.pending {
|
||||
color: var(--theia-descriptionForeground);
|
||||
}
|
||||
|
||||
.codex-tool.todo-item-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(var(--theia-ui-padding) * 2 / 3);
|
||||
}
|
||||
|
||||
.codex-tool.todo-item-text {
|
||||
flex: 1;
|
||||
color: var(--theia-foreground);
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.codex-tool.todo-item.status-completed .codex-tool.todo-item-text {
|
||||
text-decoration: line-through;
|
||||
color: var(--theia-descriptionForeground);
|
||||
}
|
||||
|
||||
.codex-tool.todo-priority {
|
||||
padding: 2px 6px;
|
||||
border-radius: calc(var(--theia-ui-padding) / 3);
|
||||
font-size: var(--theia-ui-font-size0);
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.codex-tool.todo-priority.priority-high {
|
||||
background-color: rgba(var(--theia-charts-red-rgb, 204, 0, 0), 0.8);
|
||||
color: var(--theia-button-foreground);
|
||||
}
|
||||
|
||||
.codex-tool.todo-priority.priority-medium {
|
||||
background-color: rgba(var(--theia-charts-orange-rgb, 255, 165, 0), 0.8);
|
||||
color: var(--theia-button-foreground);
|
||||
}
|
||||
|
||||
.codex-tool.todo-priority.priority-low {
|
||||
background-color: rgba(var(--theia-charts-blue-rgb, 0, 122, 204), 0.8);
|
||||
color: var(--theia-button-foreground);
|
||||
}
|
||||
|
||||
.codex-tool.todo-list-error {
|
||||
padding: var(--theia-ui-padding);
|
||||
color: var(--theia-errorForeground);
|
||||
background-color: var(--theia-errorBackground);
|
||||
border-radius: var(--theia-ui-padding);
|
||||
margin: var(--theia-ui-padding) 0;
|
||||
}
|
||||
|
||||
.codex-tool.todo-item.status-completed {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.codex-tool.todo-item.status-in-progress {
|
||||
background-color: rgba(var(--theia-progressBar-background-rgb, 0, 122, 204), 0.05);
|
||||
}
|
||||
|
||||
.codex-tool.todo-item.status-in-progress .codex-tool.todo-item-text {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.codex-tool.progress-text {
|
||||
color: var(--theia-descriptionForeground);
|
||||
font-size: var(--theia-ui-font-size0);
|
||||
}
|
||||
|
||||
/* WebSearch Renderer Styles */
|
||||
.codex-tool.search-result {
|
||||
padding: calc(var(--theia-ui-padding) / 2) 0;
|
||||
border-bottom: var(--theia-border-width) solid var(--theia-sideBarSectionHeader-border);
|
||||
}
|
||||
|
||||
.codex-tool.search-result:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.codex-tool.search-result-title {
|
||||
font-weight: 600;
|
||||
color: var(--theia-foreground);
|
||||
margin-bottom: calc(var(--theia-ui-padding) / 3);
|
||||
}
|
||||
|
||||
.codex-tool.search-result-link {
|
||||
color: var(--theia-textLink-foreground);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.codex-tool.search-result-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.codex-tool.search-result-url {
|
||||
color: var(--theia-descriptionForeground);
|
||||
font-size: var(--theia-ui-font-size0);
|
||||
font-family: var(--theia-ui-font-family-mono);
|
||||
margin-bottom: calc(var(--theia-ui-padding) / 3);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.codex-tool.search-result-snippet {
|
||||
color: var(--theia-foreground);
|
||||
font-size: var(--theia-ui-font-size0);
|
||||
line-height: 1.4;
|
||||
}
|
||||
0
packages/ai-codex/src/common/.gitkeep
Normal file
0
packages/ai-codex/src/common/.gitkeep
Normal file
32
packages/ai-codex/src/common/codex-preferences.ts
Normal file
32
packages/ai-codex/src/common/codex-preferences.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
// *****************************************************************************
|
||||
// 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 { AI_CORE_PREFERENCES_TITLE } from '@theia/ai-core/lib/common/ai-core-preferences';
|
||||
import { nls, PreferenceSchema } from '@theia/core';
|
||||
|
||||
export const CODEX_API_KEY_PREF = 'ai-features.codex.apiKey';
|
||||
|
||||
export const CodexPreferencesSchema: PreferenceSchema = {
|
||||
properties: {
|
||||
[CODEX_API_KEY_PREF]: {
|
||||
type: 'string',
|
||||
markdownDescription: nls.localize('theia/ai/codex/apiKey/description',
|
||||
'OpenAI API key for Codex. If not set, falls back to the shared OpenAI API key (`ai-features.openAiOfficial.openAiApiKey`). ' +
|
||||
'Can also be set via `OPENAI_API_KEY` environment variable.'),
|
||||
title: AI_CORE_PREFERENCES_TITLE,
|
||||
},
|
||||
}
|
||||
};
|
||||
46
packages/ai-codex/src/common/codex-service.ts
Normal file
46
packages/ai-codex/src/common/codex-service.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
// *****************************************************************************
|
||||
// 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 type {
|
||||
ThreadEvent,
|
||||
ThreadOptions
|
||||
} from '@openai/codex-sdk';
|
||||
|
||||
export const CODEX_SERVICE_PATH = '/services/codex';
|
||||
|
||||
export interface CodexRequest {
|
||||
prompt: string;
|
||||
options?: Partial<ThreadOptions>;
|
||||
sessionId?: string;
|
||||
sandboxMode?: 'read-only' | 'workspace-write' | 'danger-full-access';
|
||||
}
|
||||
|
||||
export interface CodexBackendRequest extends CodexRequest {
|
||||
apiKey?: string;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
export const CodexClient = Symbol('CodexClient');
|
||||
export interface CodexClient {
|
||||
sendToken(streamId: string, token?: ThreadEvent): void;
|
||||
sendError(streamId: string, error: Error): void;
|
||||
}
|
||||
|
||||
export const CodexService = Symbol('CodexService');
|
||||
export interface CodexService {
|
||||
send(request: CodexBackendRequest, streamId: string): Promise<void>;
|
||||
cancel(streamId: string): void;
|
||||
}
|
||||
18
packages/ai-codex/src/common/index.ts
Normal file
18
packages/ai-codex/src/common/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// *****************************************************************************
|
||||
// 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
|
||||
// *****************************************************************************
|
||||
|
||||
export * from './codex-service';
|
||||
export * from './codex-preferences';
|
||||
42
packages/ai-codex/src/node/codex-backend-module.ts
Normal file
42
packages/ai-codex/src/node/codex-backend-module.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
// *****************************************************************************
|
||||
// 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 { ConnectionHandler, RpcConnectionHandler } from '@theia/core';
|
||||
import { ConnectionContainerModule } from '@theia/core/lib/node/messaging/connection-container-module';
|
||||
import { ContainerModule } from '@theia/core/shared/inversify';
|
||||
import {
|
||||
CODEX_SERVICE_PATH,
|
||||
CodexClient,
|
||||
CodexService
|
||||
} from '../common/codex-service';
|
||||
import { CodexServiceImpl } from './codex-service-impl';
|
||||
|
||||
const codexConnectionModule = ConnectionContainerModule.create(({ bind }) => {
|
||||
bind(CodexServiceImpl).toSelf().inSingletonScope();
|
||||
bind(CodexService).toService(CodexServiceImpl);
|
||||
|
||||
bind(ConnectionHandler).toDynamicValue(ctx =>
|
||||
new RpcConnectionHandler<CodexClient>(CODEX_SERVICE_PATH, client => {
|
||||
const server = ctx.container.get<CodexServiceImpl>(CodexService);
|
||||
server.setClient(client);
|
||||
return server;
|
||||
})
|
||||
).inSingletonScope();
|
||||
});
|
||||
|
||||
export default new ContainerModule(bind => {
|
||||
bind(ConnectionContainerModule).toConstantValue(codexConnectionModule);
|
||||
});
|
||||
104
packages/ai-codex/src/node/codex-service-impl.ts
Normal file
104
packages/ai-codex/src/node/codex-service-impl.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
// *****************************************************************************
|
||||
// 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 { ILogger } from '@theia/core';
|
||||
import { inject, injectable, named } from '@theia/core/shared/inversify';
|
||||
import type { Thread } from '@openai/codex-sdk';
|
||||
import {
|
||||
CodexBackendRequest,
|
||||
CodexClient,
|
||||
CodexService
|
||||
} from '../common/codex-service';
|
||||
|
||||
@injectable()
|
||||
export class CodexServiceImpl implements CodexService {
|
||||
|
||||
@inject(ILogger) @named('Codex')
|
||||
private logger: ILogger;
|
||||
|
||||
private client: CodexClient;
|
||||
private sessionThreads = new Map<string, Thread>();
|
||||
private abortControllers = new Map<string, AbortController>();
|
||||
|
||||
setClient(client: CodexClient): void {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
async send(request: CodexBackendRequest, streamId: string): Promise<void> {
|
||||
if (!this.client) {
|
||||
throw new Error('Codex client not initialized');
|
||||
}
|
||||
this.sendMessages(streamId, request);
|
||||
}
|
||||
|
||||
protected async sendMessages(streamId: string, request: CodexBackendRequest): Promise<void> {
|
||||
const abortController = new AbortController();
|
||||
this.abortControllers.set(streamId, abortController);
|
||||
|
||||
try {
|
||||
const dynamicImport = new Function('specifier', 'return import(specifier)') as (specifier: string) => Promise<typeof import('@openai/codex-sdk')>;
|
||||
const { Codex } = await dynamicImport('@openai/codex-sdk');
|
||||
const codex = new Codex();
|
||||
|
||||
const sessionId = request.sessionId || streamId;
|
||||
let thread = this.sessionThreads.get(sessionId);
|
||||
if (!thread) {
|
||||
thread = codex.startThread(request.options);
|
||||
this.sessionThreads.set(sessionId, thread);
|
||||
this.logger.info(`Created new Codex thread for session: ${sessionId}`);
|
||||
} else {
|
||||
this.logger.info(`Reusing existing Codex thread for session: ${sessionId}`);
|
||||
}
|
||||
|
||||
const { events } = await thread.runStreamed(request.prompt);
|
||||
|
||||
for await (const event of events) {
|
||||
if (abortController.signal.aborted) {
|
||||
this.logger.info('Codex request cancelled:', streamId);
|
||||
break;
|
||||
}
|
||||
|
||||
this.client.sendToken(streamId, event as Parameters<CodexClient['sendToken']>[1]);
|
||||
|
||||
if (typeof event === 'object' && event !== undefined && 'type' in event) {
|
||||
const eventType = (event as { type: string }).type;
|
||||
if (eventType === 'turn.completed' || eventType === 'turn.failed') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.client.sendToken(streamId, undefined);
|
||||
} catch (e) {
|
||||
this.logger.error('Codex error:', e);
|
||||
this.client.sendError(streamId, e instanceof Error ? e : new Error(String(e)));
|
||||
} finally {
|
||||
this.cleanup(streamId);
|
||||
}
|
||||
}
|
||||
|
||||
cancel(streamId: string): void {
|
||||
const abortController = this.abortControllers.get(streamId);
|
||||
if (abortController) {
|
||||
abortController.abort('user canceled');
|
||||
}
|
||||
this.cleanup(streamId);
|
||||
}
|
||||
|
||||
protected cleanup(streamId: string): void {
|
||||
this.abortControllers.delete(streamId);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user