// ***************************************************************************** // 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, ILogger } from '@theia/core'; import { URI } from '@theia/core/lib/common/uri'; import { inject, injectable, named } 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; @inject(ILogger) @named('ai-codex:CodexChatAgent') protected readonly logger: ILogger; async invoke(request: MutableChatRequestModel): Promise { 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) { this.logger.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 { let toolCalls = request.getDataByKey(CODEX_TOOL_CALLS_KEY) as Map | undefined; if (!toolCalls) { toolCalls = new Map(); request.addData(CODEX_TOOL_CALLS_KEY, toolCalls); } return toolCalls; } protected async handleEvent(event: ThreadEvent, request: MutableChatRequestModel): Promise { 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 = {}; 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 { 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 { 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] | 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> { let originals = request.getDataByKey(CODEX_FILE_CHANGE_ORIGINALS_KEY) as Map> | 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 { 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) { this.logger.error('CodexChatAgent: Failed to capture original content for', path, error); itemOriginals.set(path, ''); } } } protected async handleFileChangeCompleted(item: FileChangeItem, request: MutableChatRequestModel): Promise { 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) { // this.logger.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 { // try { // if (!await this.fileService.exists(fileUri)) { // this.logger.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) { // this.logger.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)) { this.logger.warn(`CodexChatAgent: Skipping file change outside workspace: ${relativePath}`); return undefined; } return normalizedCandidate; } catch (error) { this.logger.error('CodexChatAgent: Failed to resolve file URI for', relativePath, error); return undefined; } } // TODO: multi-root workspace support - currently only uses the first (primary) workspace root. // In a multi-root workspace, the root URI should be resolved based on the context of the current chat request (e.g., the file being discussed). protected async getWorkspaceRootUri(): Promise { 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 { 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 { try { await this.tokenUsageService.recordTokenUsage('openai/codex', { inputTokens: usage.input_tokens, outputTokens: usage.output_tokens, cachedInputTokens: usage.cached_input_tokens, requestId: request.id }); request.response.setTokenUsage({ inputTokens: usage.input_tokens, outputTokens: usage.output_tokens, cacheCreationInputTokens: usage.cached_input_tokens, }); } catch (error) { this.logger.error('Failed to report token usage:', error); } } }