diff --git a/docs/ai-native/acp-terminal-handler-refactor.md b/docs/ai-native/acp-terminal-handler-refactor.md new file mode 100644 index 0000000000..4cfc991530 --- /dev/null +++ b/docs/ai-native/acp-terminal-handler-refactor.md @@ -0,0 +1,321 @@ +# AcpTerminalHandler 重构设计文档 + +## 背景 + +`AcpTerminalHandler` 位于 `packages/ai-native/src/node/acp/handlers/terminal.handler.ts`,是为 CLI Agent 提供终端执行能力的核心组件。 + +### 当前问题 + +`AcpTerminalHandler` 依赖了 `@opensumi/ide-terminal-next` 前端模块: + +```typescript +import { ITerminalConnection, ITerminalService } from '@opensumi/ide-terminal-next'; + +@Injectable() +export class AcpTerminalHandler { + @Autowired(ITerminalService) + private terminalService: ITerminalService; + // ... +} +``` + +**架构问题:** + +- `@opensumi/ide-terminal-next` 是 Browser/Node 混合模块,主要为前端终端 UI 提供服务 +- `AcpTerminalHandler` 位于纯 Node 层(`src/node/`),依赖前端模块造成不必要的耦合 +- 在某些部署场景(如纯服务端模式)下,可能不需要加载完整的 terminal-next 模块 + +## 重构目标 + +1. **移除依赖**:移除 `AcpTerminalHandler` 对 `@opensumi/ide-terminal-next` 的依赖 +2. **保持功能**:保持现有终端功能不降级(支持 PTY、交互式命令) +3. **最小改动**:保持现有接口和使用方式不变,只改内部实现 + +--- + +## 设计方案 + +### 方案概述 + +使用 `node-pty` 直接替代 `ITerminalService`,在 Node 层直接管理 PTY 进程。 + +``` +重构前: +AcpTerminalHandler → ITerminalService → node-pty + +重构后: +AcpTerminalHandler → node-pty(直接使用) +``` + +### 依赖变更 + +在 `packages/ai-native/package.json` 中添加: + +```json +{ + "dependencies": { + "node-pty": "1.0.0" + } +} +``` + +### 核心改动 + +#### 1. 导入变更 + +```typescript +// 移除 +import { ITerminalConnection, ITerminalService } from '@opensumi/ide-terminal-next'; + +// 新增 +import * as pty from 'node-pty'; +``` + +#### 2. TerminalSession 接口调整 + +```typescript +// 移除 ITerminalConnection 依赖 +interface TerminalSession { + terminalId: string; + sessionId: string; + // connection: ITerminalConnection; // 移除 + ptyProcess: pty.IPty; // 新增 + outputBuffer: string; + outputByteLimit: number; + exited: boolean; + exitCode?: number; + killed: boolean; + startTime: number; +} +``` + +#### 3. createTerminal 方法重构 + +```typescript +// 旧实现 +async createTerminal(request: TerminalRequest): Promise { + const terminalId = uuid(); + + // 权限检查... + + const connection = await this.terminalService.createConnection( + { + name: `ACP Terminal ${terminalId.substring(0, 8)}`, + cwd: request.cwd, + executable: request.command, + args: request.args, + env, + }, + terminalId, + ); + + connection.onData((data) => { ... }); + connection.onExit((code) => { ... }); + + // ... +} + +// 新实现 +async createTerminal(request: TerminalRequest): Promise { + const terminalId = uuid(); + + // 权限检查... + + // 合并环境变量 + const env = { + ...process.env, + ...request.env, + }; + + // 使用 node-pty 直接创建 PTY 进程 + const ptyProcess = pty.spawn(request.command, request.args || [], { + name: 'xterm-256color', + cwd: request.cwd || process.cwd(), + env, + cols: 80, // 默认值,ACP 场景可能不需要调整 + rows: 24, + }); + + const terminalSession: TerminalSession = { + terminalId, + sessionId: request.sessionId, + ptyProcess, + outputBuffer: '', + outputByteLimit: request.outputByteLimit ?? this.defaultOutputLimit, + exited: false, + killed: false, + startTime: Date.now(), + }; + + // 监听输出 + ptyProcess.onData((data) => { + if (!terminalSession.killed) { + terminalSession.outputBuffer += data; + + // 滑动窗口截断 + const bufferSize = Buffer.byteLength(terminalSession.outputBuffer, 'utf8'); + if (bufferSize > terminalSession.outputByteLimit) { + const keepSize = Math.floor(terminalSession.outputByteLimit * 0.8); + terminalSession.outputBuffer = terminalSession.outputBuffer.slice(-keepSize); + } + } + }); + + // 监听退出 + ptyProcess.onExit((code) => { + terminalSession.exited = true; + terminalSession.exitCode = code; + this.logger?.log(`Terminal ${terminalId} exited with code ${code}`); + }); + + this.terminals.set(terminalId, terminalSession); + + return { terminalId }; +} +``` + +#### 4. killTerminal 方法调整 + +```typescript +// 旧实现 +connection.dispose(); // ITerminalConnection 的方法 + +// 新实现 +ptyProcess.kill(); // node-pty 的方法 +``` + +#### 5. releaseTerminal 方法调整 + +```typescript +// 新增:显式释放 PTY 资源 +const session = this.terminals.get(terminalId); +if (session && !session.exited) { + session.ptyProcess.kill(); +} +this.terminals.delete(terminalId); +``` + +--- + +## 接口兼容性 + +### 保持不变的接口 + +以下接口保持完全兼容,调用方无需修改: + +| 方法 | 说明 | +| ------------------------------------ | ----------------------- | +| `createTerminal(request)` | 创建终端并执行命令 | +| `getTerminalOutput(request)` | 获取终端输出缓冲 | +| `waitForTerminalExit(request)` | 等待终端退出(带超时) | +| `killTerminal(request)` | 强制终止终端 | +| `releaseTerminal(request)` | 释放终端资源 | +| `releaseSessionTerminals(sessionId)` | 批量释放 Session 的终端 | +| `setPermissionCallback(callback)` | 设置权限回调 | +| `configure(options)` | 配置选项 | + +### 内部实现变更 + +| 变更点 | 旧实现 | 新实现 | +| -------- | ------------------------------------- | --------------------- | +| PTY 创建 | `ITerminalService.createConnection()` | `node-pty.spawn()` | +| 输出监听 | `connection.onData()` | `ptyProcess.onData()` | +| 退出监听 | `connection.onExit()` | `ptyProcess.onExit()` | +| 终止进程 | `connection.dispose()` | `ptyProcess.kill()` | + +--- + +## 风险与缓解 + +### 风险 1:node-pty 是 native 模块 + +**问题**:`node-pty` 需要编译原生代码,可能在某些平台上有兼容性问题。 + +**缓解措施**: + +- `node-pty` 是成熟稳定的库,VS Code、OpenSumi terminal-next 都在使用 +- OpenSumi 已经在 `@opensumi/ide-terminal-next` 中依赖了 `node-pty@1.0.0` +- 支持 Windows、macOS、Linux 主流平台 + +### 风险 2:环境变量处理差异 + +**问题**:`ITerminalService` 有复杂的环境变量处理逻辑(如 shell 集成)。 + +**缓解措施**: + +- ACP 场景不需要 shell 集成等高级功能 +- 直接继承 `process.env` 并合并用户传入的环境变量 +- 保持与现有逻辑一致 + +### 风险 3:终端尺寸问题 + +**问题**:`node-pty.spawn()` 需要 `cols` 和 `rows` 参数。 + +**缓解措施**: + +- 使用默认值(80x24),符合标准终端尺寸 +- ACP 场景主要用于命令执行,不涉及前端 UI 展示 +- 后续可根据需要添加动态调整支持 + +--- + +## 测试计划 + +### 单元测试 + +1. **createTerminal**:验证 PTY 进程创建成功 +2. **getTerminalOutput**:验证输出缓冲正确 +3. **waitForTerminalExit**:验证等待退出逻辑 +4. **killTerminal**:验证强制终止逻辑 +5. **releaseTerminal**:验证资源释放逻辑 +6. **权限回调**:验证权限被拒绝时不创建终端 + +### 集成测试 + +1. 执行简单命令(`echo "hello"`) +2. 执行交互式命令(如需要) +3. 验证超时处理 +4. 验证并发多个终端 + +--- + +## 实施步骤 + +1. **准备阶段** + + - [ ] 在 `package.json` 中添加 `node-pty` 依赖 + - [ ] 运行 `yarn install` + +2. **代码修改** + + - [ ] 修改导入语句 + - [ ] 修改 `TerminalSession` 接口 + - [ ] 重构 `createTerminal` 方法 + - [ ] 重构 `killTerminal` 方法 + - [ ] 重构 `releaseTerminal` 方法 + +3. **验证阶段** + + - [ ] 编译检查通过 + - [ ] 运行单元测试 + - [ ] 手动验证 ACP 功能 + +4. **清理阶段** + - [ ] 移除对 `@opensumi/ide-terminal-next` 的导入 + - [ ] 检查是否还有其他文件依赖 + +--- + +## 参考文档 + +- [node-pty GitHub](https://github.com/microsoft/node-pty) +- [OpenSumi terminal-next 实现](../packages/terminal-next/src/node/pty.ts) +- [VS Code Terminal Process](https://github.com/microsoft/vscode/blob/main/src/vs/platform/terminal/node/terminalProcess.ts) + +--- + +## 变更记录 + +| 日期 | 版本 | 变更内容 | 作者 | +| ---------- | ---- | -------- | ---- | +| 2026-03-18 | v1.0 | 初始版本 | - | diff --git a/packages/ai-native/__test__/browser/chat/chat-manager.service.test.ts b/packages/ai-native/__test__/browser/chat/chat-manager.service.test.ts new file mode 100644 index 0000000000..c52a8e4c0d --- /dev/null +++ b/packages/ai-native/__test__/browser/chat/chat-manager.service.test.ts @@ -0,0 +1,806 @@ +import { PreferenceService } from '@opensumi/ide-core-browser'; +import { AINativeSettingSectionsId, CancellationToken, Emitter } from '@opensumi/ide-core-common'; +import { ChatFeatureRegistryToken } from '@opensumi/ide-core-common/lib/types/ai-native'; +import { createBrowserInjector } from '@opensumi/ide-dev-tool/src/injector-helper'; +import { MockInjector } from '@opensumi/ide-dev-tool/src/mock-injector'; + +import { ChatManagerService } from '../../../src/browser/chat/chat-manager.service'; +import { ChatFeatureRegistry } from '../../../src/browser/chat/chat.feature.registry'; +import { ISessionModel, ISessionProvider } from '../../../src/browser/chat/session-provider'; +import { ISessionProviderRegistry } from '../../../src/browser/chat/session-provider-registry'; +import { IChatAgentService } from '../../../src/common'; + +describe('ChatManagerService', () => { + let injector: MockInjector; + let chatManagerService: ChatManagerService; + let mockSessionProviderRegistry: jest.Mocked; + let mockMainProvider: jest.Mocked; + let mockChatAgentService: jest.Mocked; + let mockPreferenceService: jest.Mocked; + let mockChatFeatureRegistry: jest.Mocked; + + const mockSessionData: ISessionModel[] = [ + { + sessionId: 'test-session-1', + modelId: 'test-model', + history: { + additional: {}, + messages: [ + { + role: 'user' as any, + content: 'Hello', + id: '', + order: 0, + }, + { + role: 'assistant' as any, + content: 'Hi there!', + id: '', + order: 0, + }, + ], + }, + requests: [], + }, + ]; + + beforeEach(() => { + jest.useFakeTimers(); + + mockMainProvider = { + id: 'local-storage', + canHandle: jest.fn().mockReturnValue(true), + loadSessions: jest.fn().mockResolvedValue(mockSessionData), + loadSession: jest.fn().mockResolvedValue(mockSessionData[0]), + saveSessions: jest.fn().mockResolvedValue(undefined), + }; + + mockSessionProviderRegistry = { + initialize: jest.fn(), + getProvider: jest.fn().mockReturnValue(mockMainProvider), + getProviderBySessionId: jest.fn().mockReturnValue(mockMainProvider), + getAllProviders: jest.fn().mockReturnValue([mockMainProvider]), + registerProvider: jest.fn().mockReturnValue({ dispose: jest.fn() }), + } as unknown as jest.Mocked; + + mockChatAgentService = { + invokeAgent: jest.fn().mockResolvedValue({}), + getFollowups: jest.fn().mockResolvedValue([]), + hasAgent: jest.fn().mockReturnValue(true), + getAgent: jest.fn(), + registerAgent: jest.fn(), + updateAgent: jest.fn(), + parseMessage: jest.fn(), + getAgents: jest.fn().mockReturnValue([]), + getSlashCommands: jest.fn().mockResolvedValue([]), + } as unknown as jest.Mocked; + + mockPreferenceService = { + get: jest.fn(), + onPreferenceChanged: new Emitter().event, + } as unknown as jest.Mocked; + + mockChatFeatureRegistry = { + getFeatures: jest.fn().mockReturnValue([]), + } as unknown as jest.Mocked; + + injector = createBrowserInjector( + [], + new MockInjector([ + { + token: ISessionProviderRegistry, + useValue: mockSessionProviderRegistry, + }, + { + token: IChatAgentService, + useValue: mockChatAgentService, + }, + { + token: PreferenceService, + useValue: mockPreferenceService, + }, + { + token: ChatFeatureRegistryToken, + useValue: mockChatFeatureRegistry, + }, + ]), + ); + + chatManagerService = injector.get(ChatManagerService); + }); + + afterEach(() => { + chatManagerService.dispose(); + jest.useRealTimers(); + }); + + describe('init()', () => { + it('should call getAllProviders and load sessions from the first matching provider', async () => { + await chatManagerService.init(); + + expect(mockSessionProviderRegistry.getAllProviders).toHaveBeenCalled(); + expect(mockMainProvider.loadSessions).toHaveBeenCalled(); + }); + + it('should add loaded sessions to sessionModels cache', async () => { + await chatManagerService.init(); + + const session = chatManagerService.getSession('test-session-1'); + expect(session).toBeDefined(); + expect(session?.sessionId).toBe('test-session-1'); + }); + + it('should restore modelId from session data', async () => { + await chatManagerService.init(); + + const session = chatManagerService.getSession('test-session-1'); + expect(session?.modelId).toBe('test-model'); + }); + + it('should fire storageInit event after loading', async () => { + const initCallback = jest.fn(); + chatManagerService.onStorageInit(initCallback); + + await chatManagerService.init(); + + expect(initCallback).toHaveBeenCalled(); + }); + + it('should filter out sessions with empty message history', async () => { + const emptySessionData: ISessionModel[] = [ + { + sessionId: 'empty-session', + modelId: 'test-model', + history: { + additional: {}, + messages: [], + }, + requests: [], + }, + ...mockSessionData, + ]; + mockMainProvider.loadSessions.mockResolvedValue(emptySessionData); + + await chatManagerService.init(); + + expect(chatManagerService.getSession('empty-session')).toBeUndefined(); + expect(chatManagerService.getSession('test-session-1')).toBeDefined(); + }); + + it('should restore requests from session data', async () => { + const sessionWithRequests: ISessionModel[] = [ + { + sessionId: 'session-with-requests', + modelId: 'test-model', + history: { + additional: {}, + messages: [{ role: 'user' as any, content: 'Hello' }], + }, + requests: [ + { + requestId: 'req-1', + message: { prompt: 'Hello', agentId: 'test-agent' }, + response: { + isCanceled: false, + responseText: 'Hi there!', + responseContents: [], + responseParts: [], + errorDetails: undefined, + followups: undefined, + }, + }, + ], + }, + ]; + mockMainProvider.loadSessions.mockResolvedValue(sessionWithRequests); + + await chatManagerService.init(); + + const session = chatManagerService.getSession('session-with-requests'); + expect(session).toBeDefined(); + const requests = session!.getRequests(); + expect(requests.length).toBe(1); + expect(requests[0].requestId).toBe('req-1'); + expect(requests[0].message.prompt).toBe('Hello'); + expect(requests[0].response.responseText).toBe('Hi there!'); + expect(requests[0].response.isComplete).toBe(true); + }); + }); + + describe('startSession()', () => { + it('should create a new session with unique sessionId', () => { + const session = chatManagerService.startSession(); + + expect(session).toBeDefined(); + expect(session.sessionId).toBeDefined(); + expect(chatManagerService.getSession(session.sessionId)).toBe(session); + }); + + it('should add session to sessionModels cache', () => { + const session = chatManagerService.startSession(); + + expect(chatManagerService.getSession(session.sessionId)).toBe(session); + }); + + it('should create multiple sessions with different ids', () => { + const session1 = chatManagerService.startSession(); + const session2 = chatManagerService.startSession(); + + expect(session1.sessionId).not.toBe(session2.sessionId); + expect(chatManagerService.getSession(session1.sessionId)).toBe(session1); + expect(chatManagerService.getSession(session2.sessionId)).toBe(session2); + }); + }); + + describe('getSession()', () => { + it('should return existing session', () => { + const session = chatManagerService.startSession(); + + const retrieved = chatManagerService.getSession(session.sessionId); + + expect(retrieved).toBe(session); + }); + + it('should return undefined for non-existent session', () => { + const retrieved = chatManagerService.getSession('non-existent-id'); + + expect(retrieved).toBeUndefined(); + }); + }); + + describe('clearSession()', () => { + it('should remove session from cache', () => { + const session = chatManagerService.startSession(); + + chatManagerService.clearSession(session.sessionId); + + expect(chatManagerService.getSession(session.sessionId)).toBeUndefined(); + }); + + it('should cancel pending request when clearing session', async () => { + const session = chatManagerService.startSession(); + const request = chatManagerService.createRequest(session.sessionId, 'Hello', 'test-agent')!; + + // Use a deferred promise so we can control when invokeAgent resolves + let resolveInvoke!: (value: any) => void; + mockPreferenceService.get.mockReturnValue('test-model'); + mockChatAgentService.invokeAgent.mockImplementation( + () => + new Promise((resolve) => { + resolveInvoke = resolve; + }), + ); + + const sendPromise = chatManagerService.sendRequest(session.sessionId, request, false); + + // Clear the session while request is pending + chatManagerService.clearSession(session.sessionId); + + expect(chatManagerService.getSession(session.sessionId)).toBeUndefined(); + + // Resolve the invoke to let sendRequest finish + resolveInvoke({}); + await sendPromise; + }); + + it('should call saveSessions after clearing', async () => { + await chatManagerService.init(); + mockMainProvider.saveSessions.mockClear(); + + const session = chatManagerService.startSession(); + + chatManagerService.clearSession(session.sessionId); + + // Advance past the debounce delay (1000ms) + jest.advanceTimersByTime(1100); + + // Flush microtasks + await Promise.resolve(); + + expect(mockMainProvider.saveSessions).toHaveBeenCalled(); + }); + + it('should throw error for non-existent session', () => { + expect(() => { + chatManagerService.clearSession('non-existent-id'); + }).toThrow('Unknown session: non-existent-id'); + }); + }); + + describe('getSessions()', () => { + it('should return all sessions', () => { + const session1 = chatManagerService.startSession(); + const session2 = chatManagerService.startSession(); + + const sessions = chatManagerService.getSessions(); + + expect(sessions).toContain(session1); + expect(sessions).toContain(session2); + expect(sessions.length).toBe(2); + }); + + it('should return empty array when no sessions', () => { + const sessions = chatManagerService.getSessions(); + + expect(sessions).toEqual([]); + }); + + it('should include sessions loaded from init', async () => { + await chatManagerService.init(); + + const sessions = chatManagerService.getSessions(); + + expect(sessions.length).toBe(1); + expect(sessions[0].sessionId).toBe('test-session-1'); + }); + + it('should include both loaded and newly created sessions', async () => { + await chatManagerService.init(); + const newSession = chatManagerService.startSession(); + + const sessions = chatManagerService.getSessions(); + + expect(sessions.length).toBe(2); + expect(sessions.some((s) => s.sessionId === 'test-session-1')).toBe(true); + expect(sessions.some((s) => s.sessionId === newSession.sessionId)).toBe(true); + }); + }); + + describe('createRequest()', () => { + it('should create a request for existing session', () => { + const session = chatManagerService.startSession(); + + const request = chatManagerService.createRequest(session.sessionId, 'Hello', 'test-agent'); + + expect(request).toBeDefined(); + expect(request?.message.prompt).toBe('Hello'); + expect(request?.message.agentId).toBe('test-agent'); + }); + + it('should create a request with command', () => { + const session = chatManagerService.startSession(); + + const request = chatManagerService.createRequest(session.sessionId, 'Hello', 'test-agent', 'explain'); + + expect(request).toBeDefined(); + expect(request?.message.command).toBe('explain'); + }); + + it('should create a request with images', () => { + const session = chatManagerService.startSession(); + + const request = chatManagerService.createRequest(session.sessionId, 'Describe this', 'test-agent', undefined, [ + 'image1.png', + 'image2.png', + ]); + + expect(request).toBeDefined(); + expect(request?.message.images).toEqual(['image1.png', 'image2.png']); + }); + + it('should return undefined if session has pending request', async () => { + const session = chatManagerService.startSession(); + const request = chatManagerService.createRequest(session.sessionId, 'Hello', 'test-agent')!; + + // Use a deferred promise to keep the request pending + let resolveInvoke!: (value: any) => void; + mockPreferenceService.get.mockReturnValue('test-model'); + mockChatAgentService.invokeAgent.mockImplementation( + () => + new Promise((resolve) => { + resolveInvoke = resolve; + }), + ); + + const sendPromise = chatManagerService.sendRequest(session.sessionId, request, false); + + // Try to create another request while one is pending + const secondRequest = chatManagerService.createRequest(session.sessionId, 'World', 'test-agent'); + expect(secondRequest).toBeUndefined(); + + // Cleanup: resolve and cancel + chatManagerService.cancelRequest(session.sessionId); + resolveInvoke({}); + await sendPromise; + }); + + it('should throw error for non-existent session', () => { + expect(() => { + chatManagerService.createRequest('non-existent-id', 'Hello', 'test-agent'); + }).toThrow('Unknown session: non-existent-id'); + }); + }); + + describe('sendRequest()', () => { + it('should send request through chat agent service', async () => { + const session = chatManagerService.startSession(); + const request = chatManagerService.createRequest(session.sessionId, 'Hello', 'test-agent')!; + + mockPreferenceService.get.mockReturnValueOnce('test-model'); + + await chatManagerService.sendRequest(session.sessionId, request, false); + + expect(mockChatAgentService.invokeAgent).toHaveBeenCalledWith( + 'test-agent', + expect.objectContaining({ + sessionId: session.sessionId, + requestId: request.requestId, + message: 'Hello', + regenerate: false, + }), + expect.any(Function), + expect.any(Array), + expect.any(Object), + ); + }); + + it('should set modelId on first request', async () => { + const session = chatManagerService.startSession(); + const request = chatManagerService.createRequest(session.sessionId, 'Hello', 'test-agent')!; + + mockPreferenceService.get.mockReturnValueOnce('test-model'); + + await chatManagerService.sendRequest(session.sessionId, request, false); + + expect(session.modelId).toBe('test-model'); + }); + + it('should not change modelId if already set and matches', async () => { + const session = chatManagerService.startSession(); + session.modelId = 'test-model'; + const request = chatManagerService.createRequest(session.sessionId, 'Hello', 'test-agent')!; + + mockPreferenceService.get.mockReturnValueOnce('test-model'); + + await chatManagerService.sendRequest(session.sessionId, request, false); + + expect(session.modelId).toBe('test-model'); + }); + + it('should throw error if model changed', async () => { + const session = chatManagerService.startSession(); + session.modelId = 'old-model'; + const request = chatManagerService.createRequest(session.sessionId, 'Hello', 'test-agent')!; + + mockPreferenceService.get.mockReturnValueOnce('new-model'); + + await expect(chatManagerService.sendRequest(session.sessionId, request, false)).rejects.toThrow( + 'Model changed unexpectedly', + ); + }); + + it('should throw error for non-existent session', async () => { + const session = chatManagerService.startSession(); + const request = chatManagerService.createRequest(session.sessionId, 'Hello', 'test-agent')!; + + await expect(chatManagerService.sendRequest('non-existent-id', request, false)).rejects.toThrow( + 'Unknown session: non-existent-id', + ); + }); + + it('should pass regenerate flag to agent', async () => { + const session = chatManagerService.startSession(); + const request = chatManagerService.createRequest(session.sessionId, 'Hello', 'test-agent')!; + + mockPreferenceService.get.mockReturnValueOnce('test-model'); + + await chatManagerService.sendRequest(session.sessionId, request, true); + + expect(mockChatAgentService.invokeAgent).toHaveBeenCalledWith( + 'test-agent', + expect.objectContaining({ + regenerate: true, + }), + expect.any(Function), + expect.any(Array), + expect.any(Object), + ); + }); + + it('should set error details from agent result', async () => { + const session = chatManagerService.startSession(); + const request = chatManagerService.createRequest(session.sessionId, 'Hello', 'test-agent')!; + + mockPreferenceService.get.mockReturnValueOnce('test-model'); + const errorDetails = { message: 'Something went wrong' }; + mockChatAgentService.invokeAgent.mockResolvedValueOnce({ errorDetails }); + + await chatManagerService.sendRequest(session.sessionId, request, false); + + expect(request.response.errorDetails).toEqual(errorDetails); + }); + + it('should set followups from agent service', async () => { + const session = chatManagerService.startSession(); + const request = chatManagerService.createRequest(session.sessionId, 'Hello', 'test-agent')!; + + mockPreferenceService.get.mockReturnValueOnce('test-model'); + const followups = [{ kind: 'reply' as const, message: 'Tell me more' }]; + mockChatAgentService.getFollowups.mockResolvedValueOnce(followups); + + await chatManagerService.sendRequest(session.sessionId, request, false); + + // Flush microtasks for followups promise to resolve + await Promise.resolve(); + await Promise.resolve(); + + expect(mockChatAgentService.getFollowups).toHaveBeenCalledWith( + 'test-agent', + session.sessionId, + CancellationToken.None, + ); + expect(request.response.followups).toEqual(followups); + expect(request.response.isComplete).toBe(true); + }); + + it('should handle cancellation during request', async () => { + const session = chatManagerService.startSession(); + const request = chatManagerService.createRequest(session.sessionId, 'Hello', 'test-agent')!; + + mockPreferenceService.get.mockReturnValueOnce('test-model'); + + // Make invokeAgent cancel the request mid-flight + mockChatAgentService.invokeAgent.mockImplementation(async (_agentId, _req, _progress, _history, token) => { + // Simulate cancellation during the request + chatManagerService.cancelRequest(session.sessionId); + return {}; + }); + + await chatManagerService.sendRequest(session.sessionId, request, false); + + expect(request.response.isCanceled).toBe(true); + }); + + it('should clean up pending request after completion', async () => { + const session = chatManagerService.startSession(); + const request = chatManagerService.createRequest(session.sessionId, 'Hello', 'test-agent')!; + + mockPreferenceService.get.mockReturnValueOnce('test-model'); + + await chatManagerService.sendRequest(session.sessionId, request, false); + + // After sendRequest completes, creating a new request should work (no pending request) + const newRequest = chatManagerService.createRequest(session.sessionId, 'World', 'test-agent'); + expect(newRequest).toBeDefined(); + }); + + it('should clean up pending request even if agent throws', async () => { + const session = chatManagerService.startSession(); + const request = chatManagerService.createRequest(session.sessionId, 'Hello', 'test-agent')!; + + mockPreferenceService.get.mockReturnValueOnce('test-model'); + mockChatAgentService.invokeAgent.mockRejectedValueOnce(new Error('Agent error')); + + await expect(chatManagerService.sendRequest(session.sessionId, request, false)).rejects.toThrow('Agent error'); + + // After error, creating a new request should work (pending request cleaned up) + const newRequest = chatManagerService.createRequest(session.sessionId, 'World', 'test-agent'); + expect(newRequest).toBeDefined(); + }); + + it('should pass context window from preferences to getMessageHistory', async () => { + const session = chatManagerService.startSession(); + const request = chatManagerService.createRequest(session.sessionId, 'Hello', 'test-agent')!; + + mockPreferenceService.get.mockImplementation((key: string) => { + if (key === AINativeSettingSectionsId.ModelID) { + return 'test-model'; + } + if (key === AINativeSettingSectionsId.ContextWindow) { + return 4096; + } + return undefined; + }); + + const getMessageHistorySpy = jest.spyOn(session, 'getMessageHistory'); + + await chatManagerService.sendRequest(session.sessionId, request, false); + + expect(getMessageHistorySpy).toHaveBeenCalledWith(4096); + getMessageHistorySpy.mockRestore(); + }); + + it('should accept progress from agent during request', async () => { + const session = chatManagerService.startSession(); + const request = chatManagerService.createRequest(session.sessionId, 'Hello', 'test-agent')!; + + mockPreferenceService.get.mockReturnValueOnce('test-model'); + + mockChatAgentService.invokeAgent.mockImplementation(async (_agentId, _req, progressCallback) => { + progressCallback({ kind: 'content', content: 'Hello ' }); + progressCallback({ kind: 'content', content: 'World' }); + return {}; + }); + + await chatManagerService.sendRequest(session.sessionId, request, false); + + expect(request.response.responseText).toContain('Hello '); + expect(request.response.responseText).toContain('World'); + }); + + it('should not accept progress after cancellation', async () => { + const session = chatManagerService.startSession(); + const request = chatManagerService.createRequest(session.sessionId, 'Hello', 'test-agent')!; + + mockPreferenceService.get.mockReturnValueOnce('test-model'); + + mockChatAgentService.invokeAgent.mockImplementation(async (_agentId, _req, progressCallback, _history, token) => { + progressCallback({ kind: 'content', content: 'Before cancel' }); + // Simulate cancellation + chatManagerService.cancelRequest(session.sessionId); + // This progress should be ignored because token is cancelled + progressCallback({ kind: 'content', content: 'After cancel' }); + return {}; + }); + + await chatManagerService.sendRequest(session.sessionId, request, false); + + expect(request.response.responseText).toContain('Before cancel'); + expect(request.response.responseText).not.toContain('After cancel'); + }); + + it('should pass command and images in request props', async () => { + const session = chatManagerService.startSession(); + const request = chatManagerService.createRequest(session.sessionId, 'Describe this', 'test-agent', 'explain', [ + 'img1.png', + ])!; + + mockPreferenceService.get.mockReturnValueOnce('test-model'); + + await chatManagerService.sendRequest(session.sessionId, request, false); + + expect(mockChatAgentService.invokeAgent).toHaveBeenCalledWith( + 'test-agent', + expect.objectContaining({ + command: 'explain', + images: ['img1.png'], + }), + expect.any(Function), + expect.any(Array), + expect.any(Object), + ); + }); + }); + + describe('cancelRequest()', () => { + it('should cancel pending request', async () => { + const session = chatManagerService.startSession(); + const request = chatManagerService.createRequest(session.sessionId, 'Hello', 'test-agent')!; + + let resolveInvoke!: (value: any) => void; + mockPreferenceService.get.mockReturnValue('test-model'); + mockChatAgentService.invokeAgent.mockImplementation( + () => + new Promise((resolve) => { + resolveInvoke = resolve; + }), + ); + + const sendPromise = chatManagerService.sendRequest(session.sessionId, request, false); + + // Cancel the request + chatManagerService.cancelRequest(session.sessionId); + + // Resolve to let sendRequest finish + resolveInvoke({}); + await sendPromise; + + expect(request.response.isCanceled).toBe(true); + }); + + it('should be safe to cancel non-existent request', () => { + expect(() => { + chatManagerService.cancelRequest('non-existent-id'); + }).not.toThrow(); + }); + + it('should allow new request after cancellation', async () => { + const session = chatManagerService.startSession(); + const request = chatManagerService.createRequest(session.sessionId, 'Hello', 'test-agent')!; + + let resolveInvoke!: (value: any) => void; + mockPreferenceService.get.mockReturnValue('test-model'); + mockChatAgentService.invokeAgent.mockImplementation( + () => + new Promise((resolve) => { + resolveInvoke = resolve; + }), + ); + + const sendPromise = chatManagerService.sendRequest(session.sessionId, request, false); + chatManagerService.cancelRequest(session.sessionId); + resolveInvoke({}); + await sendPromise; + + // Should be able to create a new request + const newRequest = chatManagerService.createRequest(session.sessionId, 'World', 'test-agent'); + expect(newRequest).toBeDefined(); + }); + }); + + describe('saveSessions()', () => { + it('should save sessions through provider', async () => { + await chatManagerService.init(); + mockMainProvider.saveSessions.mockClear(); + + chatManagerService.startSession(); + + // Trigger save and advance past debounce + chatManagerService['saveSessions'](); + jest.advanceTimersByTime(1100); + await Promise.resolve(); + + expect(mockMainProvider.saveSessions).toHaveBeenCalled(); + }); + + it('should convert ChatModel to ISessionData before saving', async () => { + await chatManagerService.init(); + mockMainProvider.saveSessions.mockClear(); + + const session = chatManagerService.startSession(); + + chatManagerService['saveSessions'](); + jest.advanceTimersByTime(1100); + await Promise.resolve(); + + const savedData = (mockMainProvider.saveSessions as jest.Mock).mock.calls[0][0]; + expect(savedData).toBeDefined(); + expect(Array.isArray(savedData)).toBe(true); + expect(savedData.some((d: ISessionModel) => d.sessionId === session.sessionId)).toBe(true); + }); + + it('should not save if mainProvider has no saveSessions method', async () => { + // Set mainProvider without saveSessions + const providerWithoutSave: ISessionProvider = { + id: 'no-save', + canHandle: jest.fn().mockReturnValue(true), + loadSessions: jest.fn().mockResolvedValue([]), + loadSession: jest.fn().mockResolvedValue(undefined), + }; + mockSessionProviderRegistry.getAllProviders.mockReturnValue([providerWithoutSave]); + + await chatManagerService.init(); + chatManagerService.startSession(); + + // Should not throw + chatManagerService['saveSessions'](); + jest.advanceTimersByTime(1100); + await Promise.resolve(); + }); + + it('should include request data in saved sessions', async () => { + await chatManagerService.init(); + mockMainProvider.saveSessions.mockClear(); + + const session = chatManagerService.startSession(); + chatManagerService.createRequest(session.sessionId, 'Hello', 'test-agent'); + + chatManagerService['saveSessions'](); + jest.advanceTimersByTime(1100); + await Promise.resolve(); + + const savedData = (mockMainProvider.saveSessions as jest.Mock).mock.calls[0][0] as ISessionModel[]; + const savedSession = savedData.find((d) => d.sessionId === session.sessionId); + expect(savedSession).toBeDefined(); + expect(savedSession!.requests.length).toBe(1); + expect(savedSession!.requests[0].message.prompt).toBe('Hello'); + }); + }); + + describe('LRU cache behavior', () => { + it('should evict oldest sessions when exceeding MAX_SESSION_COUNT', () => { + const sessions: string[] = []; + + // Create 21 sessions (MAX_SESSION_COUNT is 20) + for (let i = 0; i < 21; i++) { + const session = chatManagerService.startSession(); + sessions.push(session.sessionId); + } + + // The first session should have been evicted + expect(chatManagerService.getSession(sessions[0])).toBeUndefined(); + // The last session should still exist + expect(chatManagerService.getSession(sessions[20])).toBeDefined(); + }); + }); +}); diff --git a/packages/ai-native/__test__/node/acp/cli-agent-process-manager.test.ts b/packages/ai-native/__test__/node/acp/cli-agent-process-manager.test.ts new file mode 100644 index 0000000000..215629dc73 --- /dev/null +++ b/packages/ai-native/__test__/node/acp/cli-agent-process-manager.test.ts @@ -0,0 +1,198 @@ +import { CliAgentProcessManager, ICliAgentProcessManager } from '../../../src/node/acp/cli-agent-process-manager'; + +describe('CliAgentProcessManager', () => { + let processManager: ICliAgentProcessManager; + + beforeEach(() => { + processManager = new CliAgentProcessManager(); + }); + + afterEach(async () => { + // Clean up any running processes + await processManager.killAllAgents(); + }); + + describe('startAgent', () => { + it('should return the same processId for multiple calls with same config', async () => { + // First call - should create a new process (use long-running command) + const result1 = await processManager.startAgent('node', ['-e', 'setInterval(() => {}, 1000)'], {}, process.cwd()); + + // Second call with same config - should return existing process + const result2 = await processManager.startAgent('node', ['-e', 'setInterval(() => {}, 1000)'], {}, process.cwd()); + + // Both should return the same processId (reusing existing process) + expect(result1.processId).toBe(result2.processId); + + // Cleanup + await processManager.killAgent(); + }); + + it('should restart process when config changes', async () => { + // First call + const result1 = await processManager.startAgent('node', ['-e', 'setInterval(() => {}, 1000)'], {}, process.cwd()); + + // Second call with different cwd - should restart + const result2 = await processManager.startAgent('node', ['-e', 'setInterval(() => {}, 1000)'], {}, '/tmp'); + + // Should return the new process ID after restart + expect(result1.processId).not.toBe(result2.processId); + + // Cleanup + await processManager.killAgent(); + }); + + it('should return existing process if still running', async () => { + // Start agent + const result1 = await processManager.startAgent('node', ['-e', 'setInterval(() => {}, 1000)'], {}, process.cwd()); + + // Immediately call again - should return same process + const result2 = await processManager.startAgent('node', ['-e', 'setInterval(() => {}, 1000)'], {}, process.cwd()); + + expect(result1.processId).toBe(result2.processId); + expect(processManager.isRunning()).toBe(true); + + // Cleanup + await processManager.killAgent(); + }); + }); + + describe('isRunning', () => { + it('should return false when no process is started', () => { + expect(processManager.isRunning()).toBe(false); + }); + + it('should return true when process is running', async () => { + // Start a long-running process + await processManager.startAgent('node', ['-e', 'setInterval(() => {}, 1000)'], {}, process.cwd()); + + expect(processManager.isRunning()).toBe(true); + + // Cleanup + await processManager.killAgent(); + }); + + it('should return false after process is killed', async () => { + // Start and kill a process + await processManager.startAgent('node', ['-e', 'console.log("test")'], {}, process.cwd()); + await processManager.killAgent(); + + // Give it a moment to actually exit + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(processManager.isRunning()).toBe(false); + }); + }); + + describe('stopAgent', () => { + it('should stop the running process', async () => { + // Start a long-running process + await processManager.startAgent('node', ['-e', 'setInterval(() => {}, 1000)'], {}, process.cwd()); + + expect(processManager.isRunning()).toBe(true); + + // Stop the process + await processManager.stopAgent(); + + // Give it a moment to stop + await new Promise((resolve) => setTimeout(resolve, 1000)); + + expect(processManager.isRunning()).toBe(false); + }, 20000); + + it('should handle stopping non-existent process gracefully', async () => { + // Should not throw + await expect(processManager.stopAgent()).resolves.not.toThrow(); + }); + }); + + describe('killAgent', () => { + it('should force kill the running process', async () => { + // Start a long-running process + await processManager.startAgent('node', ['-e', 'setInterval(() => {}, 1000)'], {}, process.cwd()); + + expect(processManager.isRunning()).toBe(true); + + // Force kill + await processManager.killAgent(); + + // Give it a moment to actually exit + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(processManager.isRunning()).toBe(false); + }); + }); + + describe('listRunningAgents', () => { + it('should return empty array when no process is running', () => { + expect(processManager.listRunningAgents()).toEqual([]); + }); + + it('should return array with one processId when process is running', async () => { + await processManager.startAgent('node', ['-e', 'setInterval(() => {}, 1000)'], {}, process.cwd()); + + const running = processManager.listRunningAgents(); + + expect(running).toHaveLength(1); + expect(running[0]).toBe('singleton-agent-process'); + + // Cleanup + await processManager.killAgent(); + }); + + it('should return empty array after process is killed', async () => { + await processManager.startAgent('node', ['-e', 'setInterval(() => {}, 1000)'], {}, process.cwd()); + await processManager.killAgent(); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(processManager.listRunningAgents()).toEqual([]); + }); + }); + + describe('getExitCode', () => { + it('should return null for running process', async () => { + await processManager.startAgent('node', ['-e', 'setInterval(() => {}, 1000)'], {}, process.cwd()); + + expect(processManager.getExitCode()).toBe(null); + + // Cleanup + await processManager.killAgent(); + }); + + it('should return exit code after process exits', async () => { + // Start a process that exits with code 0 + await processManager.startAgent('node', ['-e', 'process.exit(0)'], {}, process.cwd()); + + // Wait for process to complete and exit event to be processed + await new Promise((resolve) => setTimeout(resolve, 1000)); + + expect(processManager.getExitCode()).toBe(0); + }); + }); + + describe('killAllAgents', () => { + it('should kill the running process', async () => { + await processManager.startAgent('node', ['-e', 'setInterval(() => {}, 1000)'], {}, process.cwd()); + + expect(processManager.isRunning()).toBe(true); + + await processManager.killAllAgents(); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(processManager.isRunning()).toBe(false); + }); + }); + + describe('singleton pattern', () => { + it('should reuse the same process for multiple startAgent calls', async () => { + const result1 = await processManager.startAgent('node', ['-e', 'setInterval(() => {}, 1000)'], {}, process.cwd()); + const result2 = await processManager.startAgent('node', ['-e', 'setInterval(() => {}, 1000)'], {}, process.cwd()); + + // Second call should return the same process (not restart) + expect(result1.processId).toBe(result2.processId); + + await processManager.killAgent(); + }); + }); +}); diff --git a/packages/ai-native/__test__/node/acp/handlers/terminal.handler.test.ts b/packages/ai-native/__test__/node/acp/handlers/terminal.handler.test.ts new file mode 100644 index 0000000000..88851bb9ab --- /dev/null +++ b/packages/ai-native/__test__/node/acp/handlers/terminal.handler.test.ts @@ -0,0 +1,639 @@ +import * as pty from 'node-pty'; + +import { ACPErrorCode } from '../../../../src/node/acp/handlers/constants'; +import { + AcpTerminalHandler, + TerminalRequest, + TerminalResponse, +} from '../../../../src/node/acp/handlers/terminal.handler'; + +// Mock node-pty +jest.mock('node-pty', () => { + const mockPtyProcess = { + pid: 12345, + onData: jest.fn((cb: (data: string) => void) => { + // Store callback for later use + (mockPtyProcess as any)._onDataCallback = cb; + return { dispose: jest.fn() }; + }), + onExit: jest.fn((cb: (event: { exitCode: number }) => void) => { + // Store callback for later use + (mockPtyProcess as any)._onExitCallback = cb; + return { dispose: jest.fn() }; + }), + write: jest.fn(), + resize: jest.fn(), + kill: jest.fn(), + pause: jest.fn(), + resume: jest.fn(), + }; + + return { + spawn: jest.fn(() => mockPtyProcess), + }; +}); + +/** + * Mock Logger for testing + */ +class MockLogger { + infoMessages: string[] = []; + warnMessages: string[] = []; + errorMessages: string[] = []; + logMessages: string[] = []; + debugMessages: string[] = []; + + log(message: string, ...args: any[]) { + this.logMessages.push(message); + } + + info(message: string, ...args: any[]) { + this.infoMessages.push(message); + } + + warn(message: string, ...args: any[]) { + this.warnMessages.push(message); + } + + error(message: string, ...args: any[]) { + this.errorMessages.push(message); + } + + debug(message: string, ...args: any[]) { + this.debugMessages.push(message); + } +} + +describe('AcpTerminalHandler', () => { + let handler: AcpTerminalHandler; + let mockLogger: MockLogger; + + beforeEach(() => { + jest.clearAllMocks(); + mockLogger = new MockLogger(); + + // Create handler with mocked dependencies + handler = new AcpTerminalHandler(); + // Use Object.defineProperty to bypass readonly setter + Object.defineProperty(handler, 'logger', { + value: mockLogger, + writable: true, + configurable: true, + }); + }); + + afterEach(async () => { + // Clean up any remaining terminals by directly accessing the private map + const terminals = (handler as any).terminals as Map; + if (terminals && terminals.size > 0) { + for (const [terminalId] of terminals) { + try { + await handler.releaseTerminal({ sessionId: 'test-session', terminalId }); + } catch { + // Ignore cleanup errors + } + } + } + }); + + describe('createTerminal', () => { + it('should create a terminal and return terminalId', async () => { + const request: TerminalRequest = { + sessionId: 'test-session-1', + command: 'node', + args: ['-e', 'console.log("hello"); process.exit(0)'], + cwd: process.cwd(), + }; + + const result = await handler.createTerminal(request); + + expect(result.error).toBeUndefined(); + expect(result.terminalId).toBeDefined(); + expect(result.terminalId!.length).toBeGreaterThan(0); + + // Check log output + expect(mockLogger.logMessages.some((m) => m.includes('createTerminal called'))).toBe(true); + expect(mockLogger.logMessages.some((m) => m.includes('Terminal created successfully'))).toBe(true); + + // Verify pty.spawn was called + expect(pty.spawn).toHaveBeenCalled(); + }); + + it('should spawn a PTY process with correct parameters', async () => { + const request: TerminalRequest = { + sessionId: 'test-session-2', + command: 'echo', + args: ['test'], + cwd: '/tmp', + env: { TEST_VAR: 'test_value' }, + }; + + const result = await handler.createTerminal(request); + + expect(result.error).toBeUndefined(); + expect(result.terminalId).toBeDefined(); + + // Verify pty.spawn was called with correct arguments + expect(pty.spawn).toHaveBeenCalledWith( + 'echo', + ['test'], + expect.objectContaining({ + name: 'xterm-256color', + cwd: '/tmp', + cols: 80, + rows: 24, + }), + ); + + // Verify the terminal was created by checking logs + expect(mockLogger.logMessages.some((m) => m.includes('Spawning PTY process'))).toBe(true); + expect(mockLogger.logMessages.some((m) => m.includes('PTY process spawned successfully'))).toBe(true); + }); + + it('should handle permission callback and reject when not permitted', async () => { + // Set up permission callback that always rejects + handler.setPermissionCallback(async () => false); + + const request: TerminalRequest = { + sessionId: 'test-session-3', + command: 'ls', + args: ['-la'], + }; + + const result = await handler.createTerminal(request); + + expect(result.error).toBeDefined(); + expect(result.error!.code).toBe(ACPErrorCode.FORBIDDEN); + expect(result.error!.message).toBe('Command execution permission denied'); + + // Verify permission was checked + expect(mockLogger.warnMessages.some((m) => m.includes('permission denied'))).toBe(true); + + // Verify pty.spawn was NOT called (permission denied) + expect(pty.spawn).not.toHaveBeenCalled(); + }); + + it('should handle permission callback and proceed when permitted', async () => { + // Set up permission callback that always allows + handler.setPermissionCallback(async () => true); + + const request: TerminalRequest = { + sessionId: 'test-session-4', + command: 'node', + args: ['-e', 'process.exit(0)'], + }; + + const result = await handler.createTerminal(request); + + expect(result.error).toBeUndefined(); + expect(result.terminalId).toBeDefined(); + + // Verify permission was checked and granted + expect(mockLogger.logMessages.some((m) => m.includes('Checking permission'))).toBe(true); + expect(mockLogger.logMessages.some((m) => m.includes('Permission granted'))).toBe(true); + + // Verify pty.spawn was called + expect(pty.spawn).toHaveBeenCalled(); + }); + + it('should use default command when command is not provided', async () => { + const request: TerminalRequest = { + sessionId: 'test-session-5', + // No command specified, should default to /bin/sh + }; + + const result = await handler.createTerminal(request); + + // Should still create a terminal (with default shell) + expect(result.error).toBeUndefined(); + expect(result.terminalId).toBeDefined(); + + // Verify spawn was called with /bin/sh + expect(pty.spawn).toHaveBeenCalledWith('/bin/sh', expect.any(Array), expect.any(Object)); + }); + + it('should merge environment variables correctly', async () => { + const customEnv = { + CUSTOM_VAR: 'custom_value', + PATH: '/custom/path', + }; + + const request: TerminalRequest = { + sessionId: 'test-session-6', + command: 'node', + args: ['-e', 'console.log(process.env.CUSTOM_VAR);'], + env: customEnv, + }; + + const result = await handler.createTerminal(request); + + expect(result.error).toBeUndefined(); + expect(result.terminalId).toBeDefined(); + + // Verify env was merged (should include process.env) + const spawnCall = (pty.spawn as jest.Mock).mock.calls[0]; + const spawnOptions = spawnCall[2]; + expect(spawnOptions.env).toMatchObject(customEnv); + }); + }); + + describe('getTerminalOutput', () => { + it('should return error when terminal not found', async () => { + const request: TerminalRequest = { + sessionId: 'test-session', + terminalId: 'non-existent-terminal', + }; + + const result = await handler.getTerminalOutput(request); + + expect(result.error).toBeDefined(); + expect(result.error!.code).toBe(ACPErrorCode.RESOURCE_NOT_FOUND); + expect(result.error!.message).toBe('Terminal not found'); + }); + + it('should return error when session mismatch', async () => { + // First create a terminal + const createResult = await handler.createTerminal({ + sessionId: 'session-a', + command: 'node', + args: ['-e', 'process.exit(0)'], + }); + + // Then try to get output with different session + const result = await handler.getTerminalOutput({ + sessionId: 'session-b', // Different session + terminalId: createResult.terminalId, + }); + + expect(result.error).toBeDefined(); + expect(result.error!.code).toBe(ACPErrorCode.SERVER_ERROR); + expect(result.error!.message).toBe('Session mismatch'); + }); + + it('should return output and exit status for exited terminal', async () => { + const request: TerminalRequest = { + sessionId: 'test-session-output', + command: 'node', + args: ['-e', 'console.log("hello"); process.exit(42);'], + }; + + const createResult = await handler.createTerminal(request); + + // Simulate output and exit using mock callbacks + const mockPty = (pty.spawn as jest.Mock).mock.results[0].value; + mockPty._onDataCallback && mockPty._onDataCallback('test output\n'); + mockPty._onExitCallback && mockPty._onExitCallback({ exitCode: 42 }); + + const result = await handler.getTerminalOutput({ + sessionId: 'test-session-output', + terminalId: createResult.terminalId, + }); + + expect(result.error).toBeUndefined(); + expect(result.output).toContain('test output'); + expect(result.exitStatus).toBe(42); + }); + }); + + describe('waitForTerminalExit', () => { + it('should return immediately when terminal already exited', async () => { + const request: TerminalRequest = { + sessionId: 'test-session-wait', + command: 'node', + args: ['-e', 'process.exit(0)'], + }; + + const createResult = await handler.createTerminal(request); + + // Simulate exit + const mockPty = (pty.spawn as jest.Mock).mock.results[0].value; + mockPty._onExitCallback && mockPty._onExitCallback({ exitCode: 0 }); + + const result = await handler.waitForTerminalExit({ + sessionId: 'test-session-wait', + terminalId: createResult.terminalId, + }); + + expect(result.error).toBeUndefined(); + expect(result.exitCode).toBe(0); + }); + + it('should wait for terminal to exit with timeout', async () => { + const request: TerminalRequest = { + sessionId: 'test-session-wait-long', + command: 'node', + args: ['-e', 'setTimeout(() => process.exit(0), 2000);'], + timeout: 5000, + }; + + const createResult = await handler.createTerminal(request); + + // Simulate exit after a delay + setTimeout(() => { + const mockPty = (pty.spawn as jest.Mock).mock.results[0].value; + mockPty._onExitCallback && mockPty._onExitCallback({ exitCode: 0 }); + }, 100); + + const result = await handler.waitForTerminalExit({ + sessionId: 'test-session-wait-long', + terminalId: createResult.terminalId, + timeout: 5000, + }); + + expect(result.error).toBeUndefined(); + expect(result.exitCode).toBe(0); + }); + + it('should return null exitStatus when timeout occurs', async () => { + const request: TerminalRequest = { + sessionId: 'test-session-timeout', + command: 'node', + args: ['-e', 'setTimeout(() => process.exit(0), 5000);'], + timeout: 100, // Short timeout + }; + + const createResult = await handler.createTerminal(request); + + const result = await handler.waitForTerminalExit({ + sessionId: 'test-session-timeout', + terminalId: createResult.terminalId, + timeout: 100, + }); + + // Timeout should return null exitStatus + expect(result.error).toBeUndefined(); + expect(result.exitStatus).toBeNull(); + }); + + it('should return error when terminal not found', async () => { + const result = await handler.waitForTerminalExit({ + sessionId: 'test-session', + terminalId: 'non-existent', + }); + + expect(result.error).toBeDefined(); + expect(result.error!.code).toBe(ACPErrorCode.RESOURCE_NOT_FOUND); + }); + }); + + describe('killTerminal', () => { + it('should kill a running terminal', async () => { + const request: TerminalRequest = { + sessionId: 'test-session-kill', + command: 'node', + args: ['-e', 'setInterval(() => {}, 1000);'], + }; + + const createResult = await handler.createTerminal(request); + + const result = await handler.killTerminal({ + sessionId: 'test-session-kill', + terminalId: createResult.terminalId, + }); + + expect(result.error).toBeUndefined(); + + // Verify pty.kill was called + const mockPty = (pty.spawn as jest.Mock).mock.results[0].value; + expect(mockPty.kill).toHaveBeenCalled(); + + // Verify log + expect(mockLogger.logMessages.some((m) => m.includes('Killing terminal'))).toBe(true); + }); + + it('should return success when terminal already exited', async () => { + const request: TerminalRequest = { + sessionId: 'test-session-kill-exited', + command: 'node', + args: ['-e', 'process.exit(0);'], + }; + + const createResult = await handler.createTerminal(request); + + // Simulate exit + const mockPty = (pty.spawn as jest.Mock).mock.results[0].value; + mockPty._onExitCallback && mockPty._onExitCallback({ exitCode: 0 }); + + const result = await handler.killTerminal({ + sessionId: 'test-session-kill-exited', + terminalId: createResult.terminalId, + }); + + // Should return success (already exited) + expect(result.error).toBeUndefined(); + expect(result.exitStatus).toBe(0); + }); + + it('should return error when terminal not found', async () => { + const result = await handler.killTerminal({ + sessionId: 'test-session', + terminalId: 'non-existent', + }); + + expect(result.error).toBeDefined(); + expect(result.error!.code).toBe(ACPErrorCode.RESOURCE_NOT_FOUND); + }); + + it('should return error when session mismatch', async () => { + const createResult = await handler.createTerminal({ + sessionId: 'session-a', + command: 'node', + args: ['-e', 'setInterval(() => {}, 1000);'], + }); + + const result = await handler.killTerminal({ + sessionId: 'session-b', // Different session + terminalId: createResult.terminalId, + }); + + expect(result.error).toBeDefined(); + expect(result.error!.code).toBe(ACPErrorCode.SERVER_ERROR); + expect(result.error!.message).toBe('Session mismatch'); + }); + }); + + describe('releaseTerminal', () => { + it('should release a terminal and remove it from tracking', async () => { + const request: TerminalRequest = { + sessionId: 'test-session-release', + command: 'node', + args: ['-e', 'setInterval(() => {}, 1000);'], + }; + + const createResult = await handler.createTerminal(request); + + // Release the terminal + const result = await handler.releaseTerminal({ + sessionId: 'test-session-release', + terminalId: createResult.terminalId, + }); + + expect(result.error).toBeUndefined(); + + // Verify terminal was removed by trying to get its output + const outputResult = await handler.getTerminalOutput({ + sessionId: 'test-session-release', + terminalId: createResult.terminalId, + }); + + expect(outputResult.error).toBeDefined(); + expect(outputResult.error!.message).toBe('Terminal not found'); + }); + + it('should kill PTY process when releasing non-exited terminal', async () => { + const request: TerminalRequest = { + sessionId: 'test-session-release-kill', + command: 'node', + args: ['-e', 'setInterval(() => {}, 1000);'], + }; + + const createResult = await handler.createTerminal(request); + + await handler.releaseTerminal({ + sessionId: 'test-session-release-kill', + terminalId: createResult.terminalId, + }); + + // Verify pty.kill was called + const mockPty = (pty.spawn as jest.Mock).mock.results[0].value; + expect(mockPty.kill).toHaveBeenCalled(); + + // Verify log + expect(mockLogger.logMessages.some((m) => m.includes('Releasing terminal'))).toBe(true); + }); + + it('should return empty result when terminal not found', async () => { + const result = await handler.releaseTerminal({ + sessionId: 'test-session', + terminalId: 'non-existent', + }); + + // Should return empty result (no error) for non-existent terminal + expect(result.error).toBeUndefined(); + expect(result.terminalId).toBeUndefined(); + }); + + it('should return error when session mismatch', async () => { + const createResult = await handler.createTerminal({ + sessionId: 'session-a', + command: 'node', + args: ['-e', 'setInterval(() => {}, 1000);'], + }); + + const result = await handler.releaseTerminal({ + sessionId: 'session-b', // Different session + terminalId: createResult.terminalId, + }); + + expect(result.error).toBeDefined(); + expect(result.error!.code).toBe(ACPErrorCode.SERVER_ERROR); + expect(result.error!.message).toBe('Session mismatch'); + }); + }); + + describe('releaseSessionTerminals', () => { + it('should release all terminals for a session', async () => { + const sessionId = 'test-session-multi'; + + // Create multiple terminals + const terminal1 = await handler.createTerminal({ + sessionId, + command: 'node', + args: ['-e', 'setInterval(() => {}, 1000);'], + }); + + const terminal2 = await handler.createTerminal({ + sessionId, + command: 'node', + args: ['-e', 'setInterval(() => {}, 1000);'], + }); + + const terminal3 = await handler.createTerminal({ + sessionId: 'other-session', + command: 'node', + args: ['-e', 'setInterval(() => {}, 1000);'], + }); + + // Release all terminals for the session + await handler.releaseSessionTerminals(sessionId); + + // Verify terminals for the session are released + const output1 = await handler.getTerminalOutput({ sessionId, terminalId: terminal1.terminalId }); + const output2 = await handler.getTerminalOutput({ sessionId, terminalId: terminal2.terminalId }); + const output3 = await handler.getTerminalOutput({ sessionId: 'other-session', terminalId: terminal3.terminalId }); + + expect(output1.error).toBeDefined(); // Should be released + expect(output2.error).toBeDefined(); // Should be released + expect(output3.error).toBeUndefined(); // Should still exist + + // Clean up + await handler.releaseTerminal({ sessionId: 'other-session', terminalId: terminal3.terminalId }); + }); + + it('should log the number of terminals released', async () => { + const sessionId = 'test-session-log'; + + await handler.createTerminal({ sessionId, command: 'node', args: ['-e', 'setInterval(() => {}, 1000);'] }); + await handler.createTerminal({ sessionId, command: 'node', args: ['-e', 'setInterval(() => {}, 1000);'] }); + + await handler.releaseSessionTerminals(sessionId); + + expect(mockLogger.logMessages.some((m) => m.includes('Released'))).toBe(true); + }); + }); + + describe('getSessionTerminals', () => { + it('should return all terminal IDs for a session', async () => { + const sessionId = 'test-session-list'; + + const terminal1 = await handler.createTerminal({ + sessionId, + command: 'node', + args: ['-e', 'setInterval(() => {}, 1000);'], + }); + const terminal2 = await handler.createTerminal({ + sessionId, + command: 'node', + args: ['-e', 'setInterval(() => {}, 1000);'], + }); + await handler.createTerminal({ + sessionId: 'other-session', + command: 'node', + args: ['-e', 'setInterval(() => {}, 1000);'], + }); + + const terminalIds = handler.getSessionTerminals(sessionId); + + expect(terminalIds).toHaveLength(2); + expect(terminalIds).toContain(terminal1.terminalId); + expect(terminalIds).toContain(terminal2.terminalId); + + // Clean up + await handler.releaseSessionTerminals(sessionId); + await handler.releaseSessionTerminals('other-session'); + }); + + it('should return empty array for session with no terminals', () => { + const terminalIds = handler.getSessionTerminals('non-existent-session'); + expect(terminalIds).toEqual([]); + }); + }); + + describe('configure', () => { + it('should update the default output limit', () => { + const newLimit = 2 * 1024 * 1024; // 2MB + + handler.configure({ outputLimit: newLimit }); + + // Can't directly verify the private property, but can verify no errors + expect(true).toBe(true); + }); + + it('should handle undefined outputLimit gracefully', () => { + handler.configure({}); + + // Should not throw + expect(true).toBe(true); + }); + }); +}); diff --git a/packages/ai-native/package.json b/packages/ai-native/package.json index f07d762da4..ff209caffa 100644 --- a/packages/ai-native/package.json +++ b/packages/ai-native/package.json @@ -19,6 +19,7 @@ "url": "git@github.com:opensumi/core.git" }, "dependencies": { + "@agentclientprotocol/sdk": "^0.16.1", "@ai-sdk/anthropic": "^1.1.9", "@ai-sdk/deepseek": "^0.1.11", "@ai-sdk/openai": "^1.1.9", @@ -53,6 +54,7 @@ "diff": "^7.0.0", "dom-align": "^1.7.0", "eventsource": "^3.0.5", + "node-pty": "1.0.0", "rc-collapse": "^4.0.0", "react-chat-elements": "^12.0.10", "react-highlight": "^0.15.0", diff --git a/packages/ai-native/src/browser/acp/acp-permission-rpc.service.ts b/packages/ai-native/src/browser/acp/acp-permission-rpc.service.ts new file mode 100644 index 0000000000..10acb0b3cc --- /dev/null +++ b/packages/ai-native/src/browser/acp/acp-permission-rpc.service.ts @@ -0,0 +1,64 @@ +import { Autowired, Injectable } from '@opensumi/di'; +import { RPCService } from '@opensumi/ide-connection/lib/common/rpc-service'; +import { + AcpPermissionDecision, + AcpPermissionDialogParams, + IAcpPermissionService, + ILogger, +} from '@opensumi/ide-core-common'; + +import { AcpPermissionBridgeService } from './permission-bridge.service'; + +/** + * Browser-side RPC service for ACP permission requests. + * This service is called from the Node layer to show permission dialogs in the browser. + * + * @description + * This RPC service bridges the Node.js ACP agent process with the browser UI. + * When the agent needs user permission for a tool call (file write, command execution, etc.), + * it calls this service which shows a dialog in the browser and returns the user's decision. + */ +@Injectable() +export class AcpPermissionRpcService extends RPCService implements IAcpPermissionService { + @Autowired(AcpPermissionBridgeService) + private permissionBridgeService: AcpPermissionBridgeService; + + @Autowired(ILogger) + private logger: ILogger; + + constructor() { + super(); + } + + /** + * Show permission dialog and wait for user response + * Called from Node layer via RPC + */ + async $showPermissionDialog(params: AcpPermissionDialogParams): Promise { + try { + // Call the browser-side permission bridge service + const decision = await this.permissionBridgeService.showPermissionDialog({ + requestId: params.requestId, + title: params.title, + kind: params.kind, + content: params.content, + locations: params.locations, + command: params.command, + options: params.options, + timeout: params.timeout, + }); + + return decision; + } catch (error) { + return { type: 'cancelled' }; + } + } + + /** + * Cancel a pending permission request + * Called from Node layer via RPC + */ + async $cancelRequest(requestId: string): Promise { + this.permissionBridgeService.cancelRequest(requestId); + } +} diff --git a/packages/ai-native/src/browser/acp/index.ts b/packages/ai-native/src/browser/acp/index.ts new file mode 100644 index 0000000000..78c39d5487 --- /dev/null +++ b/packages/ai-native/src/browser/acp/index.ts @@ -0,0 +1,5 @@ +export { AcpPermissionHandler } from './permission.handler'; +export { AcpPermissionBridgeService, ShowPermissionDialogParams } from './permission-bridge.service'; +export { AcpPermissionRpcService } from './acp-permission-rpc.service'; +export { PermissionDialog, PermissionDialogProps } from './permission-dialog.view'; +export { default as PermissionDialogStyles } from './permission-dialog.module.less'; diff --git a/packages/ai-native/src/browser/acp/permission-bridge.service.ts b/packages/ai-native/src/browser/acp/permission-bridge.service.ts new file mode 100644 index 0000000000..e646d67798 --- /dev/null +++ b/packages/ai-native/src/browser/acp/permission-bridge.service.ts @@ -0,0 +1,160 @@ +import { Autowired, Injectable } from '@opensumi/di'; +import { Emitter, Event, ILogger } from '@opensumi/ide-core-common'; +import { IMainLayoutService } from '@opensumi/ide-main-layout'; + +import { PermissionDialogProps } from './permission-dialog.view'; +import { PermissionDecision } from './permission.handler'; + +import type { PermissionOption, PermissionOptionKind } from '@opensumi/ide-core-common/lib/types/ai-native/acp-types'; + +export interface ShowPermissionDialogParams { + requestId: string; + title: string; + kind?: string; + content?: string; + locations?: Array<{ path: string; line?: number }>; + command?: string; + options: PermissionOption[]; + timeout: number; +} + +@Injectable() +export class AcpPermissionBridgeService { + @Autowired(ILogger) + private logger: ILogger; + + @Autowired(IMainLayoutService) + private mainLayoutService: IMainLayoutService; + + private activeDialogs = new Map(); + private pendingDecisions = new Map< + string, + { + resolve: (decision: PermissionDecision) => void; + timeout: NodeJS.Timeout; + } + >(); + + private readonly onPermissionRequest = new Emitter(); + readonly onDidRequestPermission: Event = this.onPermissionRequest.event; + + private readonly onPermissionResult = new Emitter<{ + requestId: string; + decision: PermissionDecision; + }>(); + readonly onDidReceivePermissionResult: Event<{ + requestId: string; + decision: PermissionDecision; + }> = this.onPermissionResult.event; + + /** + * Show permission dialog and wait for user response + */ + async showPermissionDialog(params: ShowPermissionDialogParams): Promise { + const requestId = params.requestId; + + // Check if dialog already exists for this request + if (this.activeDialogs.has(requestId)) { + return { type: 'cancelled' }; + } + + // Create dialog props + const dialogProps: PermissionDialogProps = { + visible: true, + requestId, + title: params.title, + kind: params.kind, + content: params.content, + locations: params.locations, + command: params.command, + options: params.options, + timeout: params.timeout, + onSelect: this.handleUserDecision.bind(this), + onClose: this.handleDialogClose.bind(this), + }; + + this.activeDialogs.set(requestId, dialogProps); + + // Emit event to show dialog + this.onPermissionRequest.fire(params); + + // Set up timeout + const timeout = setTimeout(() => { + this.handleDialogClose(requestId); + }, params.timeout); + + // Wait for decision + return new Promise((resolve) => { + this.pendingDecisions.set(requestId, { + resolve, + timeout, + }); + }); + } + + /** + * Handle user decision on permission request + */ + handleUserDecision(requestId: string, optionId: string, optionKind: PermissionOptionKind): void { + const pending = this.pendingDecisions.get(requestId); + if (!pending) { + return; + } + + clearTimeout(pending.timeout); + this.pendingDecisions.delete(requestId); + + const always = optionKind === 'allow_always' || optionKind === 'reject_always'; + const allow = optionKind === 'allow_once' || optionKind === 'allow_always'; + + const decision: PermissionDecision = { + type: allow ? 'allow' : 'reject', + optionId, + always, + }; + + this.activeDialogs.delete(requestId); + this.onPermissionResult.fire({ requestId, decision }); + pending.resolve(decision); + } + + /** + * Handle dialog close/timeout + */ + handleDialogClose(requestId: string): void { + const pending = this.pendingDecisions.get(requestId); + if (!pending) { + return; + } + + clearTimeout(pending.timeout); + this.pendingDecisions.delete(requestId); + + const decision: PermissionDecision = { type: 'timeout' }; + + this.activeDialogs.delete(requestId); + this.onPermissionResult.fire({ requestId, decision }); + pending.resolve(decision); + } + + /** + * Cancel a pending permission request + */ + cancelRequest(requestId: string): void { + this.handleDialogClose(requestId); + } + + /** + * Get active dialog count + */ + getActiveDialogCount(): number { + return this.activeDialogs.size; + } + + /** + * Get active dialogs (for debugging) + */ + getActiveDialogs(): PermissionDialogProps[] { + return Array.from(this.activeDialogs.values()); + } +} diff --git a/packages/ai-native/src/browser/acp/permission-dialog-container.module.less b/packages/ai-native/src/browser/acp/permission-dialog-container.module.less new file mode 100644 index 0000000000..61abceeba2 --- /dev/null +++ b/packages/ai-native/src/browser/acp/permission-dialog-container.module.less @@ -0,0 +1,13 @@ +.dialogContainer { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 1000; + pointer-events: none; + + > * { + pointer-events: auto; + } +} diff --git a/packages/ai-native/src/browser/acp/permission-dialog-container.tsx b/packages/ai-native/src/browser/acp/permission-dialog-container.tsx new file mode 100644 index 0000000000..8f7778480d --- /dev/null +++ b/packages/ai-native/src/browser/acp/permission-dialog-container.tsx @@ -0,0 +1,441 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; + +import { Autowired, Injectable } from '@opensumi/di'; +import { ComponentContribution, ComponentRegistry, Domain, useInjectable } from '@opensumi/ide-core-browser'; +import { getIcon } from '@opensumi/ide-core-browser/lib/components'; + +import { AcpPermissionBridgeService, ShowPermissionDialogParams } from './permission-bridge.service'; + +import type { PermissionOptionKind } from '@opensumi/ide-core-common/lib/types/ai-native/acp-types'; + +// Module load logging for debugging + +// 默认权限选项(仅作为类型参考,实际选项由后端传入) +// 后端传入的选项可能包含:allow_always, allow_once, reject_once 等 + +/** + * 简化的全局对话框状态管理 + */ +@Injectable() +class PermissionDialogManager { + private listeners: Array<(dialogs: DialogState[]) => void> = []; + private dialogs: DialogState[] = []; + + addDialog(params: ShowPermissionDialogParams) { + const exists = this.dialogs.find((d) => d.requestId === params.requestId); + + if (!exists) { + this.dialogs.push({ + requestId: params.requestId, + params, + }); + this.notifyListeners(); + } + } + + removeDialog(requestId: string) { + const index = this.dialogs.findIndex((d) => d.requestId === requestId); + if (index !== -1) { + this.dialogs.splice(index, 1); + this.notifyListeners(); + } + } + + clearAll() { + this.dialogs = []; + this.notifyListeners(); + } + + getDialogs(): DialogState[] { + return [...this.dialogs]; + } + + subscribe(listener: (dialogs: DialogState[]) => void) { + this.listeners.push(listener); + return () => { + this.listeners = this.listeners.filter((l) => l !== listener); + }; + } + + private notifyListeners() { + this.listeners.forEach((listener) => listener([...this.dialogs])); + } +} + +interface DialogState { + requestId: string; + params: ShowPermissionDialogParams; +} + +/** + * 智能文件名提取工具函数 + */ +const getAffectedFileName = (params: ShowPermissionDialogParams): string => { + // 优先从 locations 获取文件名 + const fromLocations = params.locations?.[0]?.path; + if (fromLocations) { + return fromLocations.split('/').pop() || fromLocations; + } + + return 'file'; +}; + +/** + * 智能标题生成工具函数 + */ +const getSmartTitle = (params: ShowPermissionDialogParams): string => { + const kind = params.kind; + + if (kind === 'edit' || kind === 'write') { + const fileName = getAffectedFileName(params); + return `Make this edit to ${fileName}?`; + } + + if (kind === 'execute' || kind === 'bash') { + return 'Allow this bash command?'; + } + + if (kind === 'read') { + const fileName = getAffectedFileName(params); + return `Allow read from ${fileName}?`; + } + + return params.title || 'Permission Required'; +}; + +@Injectable() +@Domain(ComponentContribution) +export class AcpPermissionDialogContribution implements ComponentContribution { + @Autowired(AcpPermissionBridgeService) + private permissionBridgeService!: AcpPermissionBridgeService; + + @Autowired(PermissionDialogManager) + private dialogManager!: PermissionDialogManager; + + constructor() { + // 监听权限请求事件 - 添加对话框 + this.permissionBridgeService.onDidRequestPermission((params: ShowPermissionDialogParams) => { + this.dialogManager.addDialog(params); + }); + + // 监听权限结果事件 - 处理超时等结果 + this.permissionBridgeService.onDidReceivePermissionResult((result) => { + // 超时或取消时关闭对话框 + if (result.decision.type === 'timeout' || result.decision.type === 'cancelled') { + this.dialogManager.removeDialog(result.requestId); + } + }); + } + + registerComponent(registry: ComponentRegistry) { + registry.register('acp-permission-dialog-container', { + id: 'acp-permission-dialog-container', + component: AcpPermissionDialogContainer, + }); + } +} + +/** + * 函数组件形式的权限对话框容器 + */ +const AcpPermissionDialogContainer: React.FC = () => { + // 状态管理 + const [dialogs, setDialogs] = useState([]); + const [focusedIndex, setFocusedIndex] = useState(0); + + const functionComponentDialogManager = useInjectable(PermissionDialogManager); + const permissionBridgeService = useInjectable(AcpPermissionBridgeService); + + // Ref 管理 + const containerRef = useRef(null); + + // 组件挂载:订阅对话框状态变化 + useEffect(() => { + const unsubscribe = functionComponentDialogManager.subscribe((newDialogs) => { + setDialogs(newDialogs); + setFocusedIndex(0); // 重置焦点索引 + }); + + // 初始化当前 dialogs + setDialogs(functionComponentDialogManager.getDialogs()); + + return unsubscribe; + }, []); + + // 键盘导航处理函数(使用 useCallback 优化性能) + const handleKeyboardNavigation = useCallback( + (e: KeyboardEvent) => { + const options = dialogs[0]?.params.options || []; + + if (dialogs.length === 0) { + return; + } + + // 数字键 1-9 支持快捷选择 + const numMatch = e.key.match(/^[1-9]$/); + if (numMatch) { + const index = parseInt(e.key, 10) - 1; + if (index < options.length) { + e.preventDefault(); + handleDialogSelect(options[index].optionId || ''); + } + return; + } + + // 箭头键导航 + if (e.key === 'ArrowDown') { + e.preventDefault(); + setFocusedIndex((prev) => Math.min(prev + 1, options.length - 1)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setFocusedIndex((prev) => Math.max(prev - 1, 0)); + } + + // 回车键选择 + if (e.key === 'Enter') { + e.preventDefault(); + if (focusedIndex < options.length) { + handleDialogSelect(options[focusedIndex].optionId || ''); + } + } + + // ESC 键取消 + if (e.key === 'Escape') { + e.preventDefault(); + handleDialogClose(); + } + }, + [dialogs, focusedIndex], + ); + + // 组件更新:动态添加/移除键盘监听 + useEffect(() => { + if (dialogs.length > 0) { + window.addEventListener('keydown', handleKeyboardNavigation); + // 添加焦点 + if (containerRef.current) { + containerRef.current.focus(); + } + } else { + window.removeEventListener('keydown', handleKeyboardNavigation); + } + + return () => { + window.removeEventListener('keydown', handleKeyboardNavigation); + }; + }, [dialogs.length, handleKeyboardNavigation]); + + // 处理用户选择 + const handleDialogSelect = useCallback( + (_optionId: string) => { + if (dialogs.length === 0) { + return; + } + const requestId = dialogs[0].requestId; + const params = dialogs[0].params; + + // Find the selected option to get its kind + const selectedOption = params.options.find((opt) => opt.optionId === _optionId); + if (!selectedOption) { + return; + } + + // PermissionOption has 'kind' field which is PermissionOptionKind + const optionKind: PermissionOptionKind = selectedOption.kind || 'allow_once'; + + // Notify the permission bridge service with the decision + permissionBridgeService.handleUserDecision(requestId, _optionId, optionKind); + + // Close dialog + functionComponentDialogManager.removeDialog(requestId); + }, + [dialogs, permissionBridgeService], + ); + + // 处理对话框关闭 + const handleDialogClose = useCallback(() => { + if (dialogs.length === 0) { + return; + } + const requestId = dialogs[0].requestId; + // Notify the permission bridge service that the dialog was cancelled + permissionBridgeService.handleDialogClose(requestId); + // Close dialog + functionComponentDialogManager.removeDialog(requestId); + }, [dialogs, permissionBridgeService]); + + // 如果没有对话框,返回null + if (dialogs.length === 0) { + return null; + } + + const currentDialog = dialogs[0]; + const params = currentDialog.params; + const smartTitle = getSmartTitle(params); + const shouldShowDescription = + ['edit', 'write', 'read', 'execute', 'bash'].includes(params.kind || '') && params.content; + + return ( +
+
+ {/* 头部:标题和关闭按钮 */} +
+
+ + ! + + {smartTitle} +
+ +
+ + {/* 描述内容 */} + {shouldShowDescription && params.content && ( +
+ {params.content} +
+ )} + + {/* 选项按钮 */} +
+ {(params.options || []).map((option, index) => { + const isFocused = focusedIndex === index; + const buttonStyle: React.CSSProperties = { + display: 'flex', + alignItems: 'center', + gap: 8, + padding: '6px 10px', + textAlign: 'left', + width: '100%', + border: 0, + borderRadius: 4, + fontSize: '0.85em', + fontWeight: isFocused ? 600 : 'normal', + cursor: 'pointer', + backgroundColor: isFocused ? 'var(--app-list-active-background)' : 'transparent', + color: isFocused ? 'var(--app-list-active-foreground)' : 'var(--app-primary-foreground)', + outline: 'none', + transition: 'background-color 0.15s', + }; + + return ( + + ); + })} +
+
+
+ ); +}; + +export default AcpPermissionDialogContainer; +export { PermissionDialogManager }; diff --git a/packages/ai-native/src/browser/acp/permission-dialog.module.less b/packages/ai-native/src/browser/acp/permission-dialog.module.less new file mode 100644 index 0000000000..fece0812c5 --- /dev/null +++ b/packages/ai-native/src/browser/acp/permission-dialog.module.less @@ -0,0 +1,121 @@ +.permissionContent { + display: flex; + flex-direction: column; + gap: 16px; +} + +.permissionDetails { + display: flex; + flex-direction: column; + gap: 12px; + background: var(--kt-panel-background); + padding: 12px; + border-radius: 4px; + border: 1px solid var(--kt-panel-border); +} + +.detailRow { + display: flex; + flex-direction: column; + gap: 4px; +} + +.detailLabel { + font-size: 12px; + color: var(--kt-text-subtoken); + font-weight: 500; +} + +.detailValue { + display: flex; + align-items: center; + gap: 8px; + font-weight: 500; +} + +.commandCode { + background: var(--kt-code-background); + padding: 8px 12px; + border-radius: 4px; + font-family: inherit; + font-size: 13px; + color: var(--kt-text-highlight); + word-break: break-all; + margin: 0; +} + +.locationList { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.locationItem { + display: flex; + align-items: center; + gap: 4px; + background: var(--kt-badge-background); + padding: 2px 8px; + border-radius: 3px; + font-size: 12px; + color: var(--kt-text-secondary); +} + +.contentPreview { + background: var(--kt-code-background); + padding: 8px; + border-radius: 4px; + max-height: 150px; + overflow: auto; + + pre { + margin: 0; + font-size: 12px; + font-family: var(--kt-code-font-family); + white-space: pre-wrap; + word-break: break-all; + } +} + +.timeoutSection { + background: var(--kt-panel-background); + padding: 12px; + border-radius: 4px; +} + +.timeoutHeader { + display: flex; + justify-content: space-between; + margin-bottom: 8px; + font-size: 12px; + color: var(--kt-text-subtoken); +} + +.timeoutValue { + font-weight: 600; + color: var(--kt-text-highlight); + min-width: 40px; + text-align: right; +} + +.warningMessage { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 8px 12px; + background: var(--kt-noticeInfo-background); + border-left: 3px solid var(--kt-noticeInfo-foreground); + border-radius: 0 4px 4px 0; + font-size: 12px; + color: var(--kt-text-secondary); + + span { + line-height: 1.4; + } +} + +.dialogFooter { + display: flex; + justify-content: flex-end; + gap: 8px; +} diff --git a/packages/ai-native/src/browser/acp/permission-dialog.view.tsx b/packages/ai-native/src/browser/acp/permission-dialog.view.tsx new file mode 100644 index 0000000000..17cbda6f5d --- /dev/null +++ b/packages/ai-native/src/browser/acp/permission-dialog.view.tsx @@ -0,0 +1,180 @@ +import React, { useEffect, useState } from 'react'; + +import { Button, Dialog, Icon } from '@opensumi/ide-components'; + +import styles from './permission-dialog.module.less'; + +import type { PermissionOptionKind, ToolCallLocation } from '@opensumi/ide-core-common/lib/types/ai-native/acp-types'; + +export interface PermissionDialogProps { + visible: boolean; + requestId: string; + title: string; + kind?: string; + content?: string; + locations?: ToolCallLocation[]; + command?: string; + options: Array<{ + optionId: string; + name: string; + kind: PermissionOptionKind; + }>; + timeout: number; + onSelect: (requestId: string, optionId: string, kind: PermissionOptionKind) => void; + onClose: (requestId: string) => void; +} + +export const PermissionDialog: React.FC = ({ + visible, + requestId, + title, + kind, + content, + locations, + command, + options, + timeout, + onSelect, + onClose, +}) => { + const [remainingTime, setRemainingTime] = useState(timeout); + // const [theme] = useDesignTheme(); + + // Countdown timer + useEffect(() => { + if (!visible || remainingTime <= 0) { + return; + } + + const interval = setInterval(() => { + setRemainingTime((prev) => { + if (prev <= 100) { + clearInterval(interval); + onClose(requestId); + return 0; + } + return prev - 100; + }); + }, 100); + + return () => clearInterval(interval); + }, [visible, remainingTime, requestId, onClose]); + + const handleOptionSelect = (optionId: string, kind: PermissionOptionKind) => { + onSelect(requestId, optionId, kind); + }; + + const getIconForKind = (kind?: string) => { + switch (kind) { + case 'write': + case 'edit': + return 'edit'; + case 'read': + return 'eye'; + case 'command': + return 'terminal'; + case 'search': + return 'search'; + default: + return 'file'; + } + }; + + // const progressPercent = (remainingTime / timeout) * 100; + + return ( + onClose(requestId)} + footer={ +
+ {options.map((option) => ( + + ))} +
+ } + message={undefined} + > +
+ {/* Permission details */} +
+ {kind && ( +
+ Operation: + + + {kind.charAt(0).toUpperCase() + kind.slice(1)} + +
+ )} + + {/* Show command if present */} + {command && ( +
+ Command: + {command} +
+ )} + + {/* Show affected files/paths */} + {locations && locations.length > 0 && ( +
+ Affected: +
+ {locations.map((loc, idx) => ( + + + {loc.path} + {loc.line && `:${loc.line}`} + + ))} +
+
+ )} + + {/* Show diff/content preview if available */} + {content && ( +
+ Preview: +
+
+                  {content.substring(0, 500)}
+                  {content.length > 500 ? '...' : ''}
+                
+
+
+ )} +
+ + {/* Timeout progress */} +
+
+ Auto-reject in + {Math.ceil(remainingTime / 1000)}s +
+ {/* */} +
+ + {/* Warning message */} +
+ + This operation was requested by the AI agent. Please review carefully. +
+
+
+ ); +}; + +export default PermissionDialog; diff --git a/packages/ai-native/src/browser/acp/permission.handler.ts b/packages/ai-native/src/browser/acp/permission.handler.ts new file mode 100644 index 0000000000..518c2dec55 --- /dev/null +++ b/packages/ai-native/src/browser/acp/permission.handler.ts @@ -0,0 +1,330 @@ +import { Autowired, Injectable } from '@opensumi/di'; +import { PreferenceService } from '@opensumi/ide-core-browser/lib/preferences'; +import { Disposable, ILogger, IStorage, STORAGE_NAMESPACE, StorageProvider, uuid } from '@opensumi/ide-core-common'; + +import type { + PermissionOption, + PermissionOptionKind, + RequestPermissionResponse, + ToolCallUpdate, +} from '@opensumi/ide-core-common/lib/types/ai-native/acp-types'; + +export interface PermissionRequest { + sessionId: string; + toolCall: ToolCallUpdate; + options: PermissionOption[]; + timeout?: number; +} + +export type PermissionDecision = + | { type: 'allow'; optionId: string; always: boolean } + | { type: 'reject'; optionId: string; always: boolean } + | { type: 'timeout' } + | { type: 'cancelled' }; + +interface PermissionRule { + id: string; + pattern: string; + kind: ToolKind; + decision: 'allow' | 'reject'; + always: boolean; + createdAt: number; +} + +type ToolKind = 'read' | 'write' | 'edit' | 'command' | 'search'; + +@Injectable() +export class AcpPermissionHandler extends Disposable { + @Autowired(ILogger) + private logger: ILogger; + + @Autowired(StorageProvider) + private storageProvider: StorageProvider; + + @Autowired(PreferenceService) + private preferenceService: PreferenceService; + + private pendingRequests = new Map< + string, + { + resolve: (decision: PermissionDecision) => void; + timeout: NodeJS.Timeout; + } + >(); + + private rules: PermissionRule[] = []; + private defaultTimeout = 60000; // 60 seconds + + private permissionStorage: IStorage; + + constructor() { + super(); + this.initStorage(); + } + + private async initStorage(): Promise { + this.permissionStorage = await this.storageProvider(STORAGE_NAMESPACE.AI_NATIVE); + this.loadRules(); + } + + /** + * Request permission for a tool operation + */ + async requestPermission(request: PermissionRequest): Promise { + const requestId = uuid(); + + // Check existing rules first + const autoDecision = this.checkRules(request); + if (autoDecision) { + this.logger.log(`Auto-${autoDecision.type}ed permission based on rule for ${request.toolCall.title}`); + return autoDecision; + } + + return new Promise((resolve) => { + // Set up timeout + const timeout = setTimeout(() => { + this.pendingRequests.delete(requestId); + this.logger.warn(`Permission request timed out: ${request.toolCall.title}`); + resolve({ type: 'timeout' }); + }, request.timeout ?? this.defaultTimeout); + + this.pendingRequests.set(requestId, { + resolve, + timeout, + }); + + // Show permission dialog + this.showPermissionDialog(requestId, request); + }); + } + + /** + * Handle user response to permission request + */ + handleUserResponse(requestId: string, optionId: string, optionKind: PermissionOptionKind): void { + const pending = this.pendingRequests.get(requestId); + if (!pending) { + this.logger.warn(`Permission request ${requestId} not found (maybe timed out)`); + return; + } + + clearTimeout(pending.timeout); + this.pendingRequests.delete(requestId); + + const always = optionKind === 'allow_always' || optionKind === 'reject_always'; + const allow = optionKind === 'allow_once' || optionKind === 'allow_always'; + + // Save rule if "always" + if (always) { + this.addRule(requestId, optionId, allow ? 'allow' : 'reject'); + } + + if (allow) { + pending.resolve({ + type: 'allow', + optionId, + always, + }); + } else { + pending.resolve({ + type: 'reject', + optionId, + always, + }); + } + } + + /** + * Cancel a pending permission request + */ + cancelRequest(requestId: string): void { + const pending = this.pendingRequests.get(requestId); + if (!pending) { + return; + } + + clearTimeout(pending.timeout); + this.pendingRequests.delete(requestId); + pending.resolve({ type: 'cancelled' }); + } + + /** + * Build permission response for the agent + */ + buildPermissionResponse(decision: PermissionDecision): RequestPermissionResponse { + switch (decision.type) { + case 'allow': + return { + outcome: { + outcome: 'selected', + optionId: decision.optionId, + }, + }; + case 'reject': + return { + outcome: { + outcome: 'selected', + optionId: decision.optionId, + }, + }; + case 'timeout': + case 'cancelled': + return { + outcome: { + outcome: 'cancelled', + }, + }; + } + } + + /** + * Get all saved permission rules + */ + getRules(): PermissionRule[] { + return [...this.rules]; + } + + /** + * Remove a permission rule + */ + removeRule(ruleId: string): void { + const index = this.rules.findIndex((r) => r.id === ruleId); + if (index !== -1) { + this.rules.splice(index, 1); + this.saveRules(); + } + } + + /** + * Clear all permission rules + */ + clearRules(): void { + this.rules = []; + this.saveRules(); + } + + private showPermissionDialog(requestId: string, request: PermissionRequest): void { + // This will be implemented to show a UI dialog + // For now, log the request + this.logger.log(`Permission request [${requestId}]: ${request.toolCall.title}`); + this.logger.log(` Kind: ${request.toolCall.kind}`); + this.logger.log(` Options: ${request.options.map((o) => o.name).join(', ')}`); + + // TODO: Implement actual dialog UI component + // - Show tool call details + // - Show affected files/directories + // - Show command preview for terminal operations + // - Provide Allow/Allow Always/Reject/Reject Always buttons + // - Show countdown timer + } + + private checkRules(request: PermissionRequest): PermissionDecision | null { + const toolKind = request.toolCall.kind || 'read'; + + // Build pattern from tool call + let pattern = ''; + if (request.toolCall.locations && request.toolCall.locations.length > 0) { + pattern = request.toolCall.locations.map((l) => l.path).join(','); + } else { + pattern = request.toolCall.title || ''; + } + + for (const rule of this.rules) { + // Check if kind matches + if (rule.kind !== toolKind) { + continue; + } + + // Check if pattern matches (exact or glob) + if (this.matchPattern(pattern, rule.pattern)) { + return { + type: rule.decision, + optionId: rule.decision === 'allow' ? 'allow_always' : 'reject_always', + always: true, + }; + } + } + + return null; + } + + private matchPattern(value: string, pattern: string): boolean { + // Simple glob matching + if (pattern.includes('*')) { + const regex = new RegExp('^' + pattern.replace(/\*/g, '.*').replace(/\?/g, '.') + '$'); + return regex.test(value); + } + return value === pattern || value.startsWith(pattern); + } + + private addRule(requestId: string, pattern: string, decision: 'allow' | 'reject'): void { + // Extract pattern from request + // This is a placeholder - actual implementation should extract from the request + const rule: PermissionRule = { + id: uuid(), + pattern, + kind: 'write', // Should be extracted from actual request + decision, + always: true, + createdAt: Date.now(), + }; + + // Remove conflicting rules + this.rules = this.rules.filter((r) => r.pattern !== pattern || r.kind !== rule.kind); + + this.rules.push(rule); + this.saveRules(); + + this.logger.log(`Permission rule added: ${pattern} => ${decision}`); + } + + private loadRules(): void { + try { + const saved = this.permissionStorage.get('acp.permission.rules', '[]'); + if (saved && saved !== '[]') { + this.rules = JSON.parse(saved); + this.logger.log(`Loaded ${this.rules.length} permission rules`); + } + } catch (e) { + this.logger.error('Failed to load permission rules:', e); + this.rules = []; + } + } + + private saveRules(): void { + try { + this.permissionStorage.set('acp.permission.rules', JSON.stringify(this.rules)); + } catch (e) { + this.logger.error('Failed to save permission rules:', e); + } + } + + /** + * Log permission audit event + */ + auditLog( + event: 'request' | 'decision', + data: { + requestId: string; + sessionId: string; + toolKind?: ToolKind; + toolTitle?: string; + decision?: string; + reason?: string; + }, + ): void { + const timestamp = new Date().toISOString(); + + // Log to console (could be extended to server-side logging) + this.logger.log(`[ACP Permission Audit ${timestamp}] ${event}:`, { + requestId: data.requestId, + sessionId: data.sessionId, + toolKind: data.toolKind, + toolTitle: data.toolTitle, + decision: data.decision, + reason: data.reason, + }); + + // TODO: Send audit logs to server + } +} diff --git a/packages/ai-native/src/browser/ai-core.contribution.ts b/packages/ai-native/src/browser/ai-core.contribution.ts index d180b117fa..878b7dadcb 100644 --- a/packages/ai-native/src/browser/ai-core.contribution.ts +++ b/packages/ai-native/src/browser/ai-core.contribution.ts @@ -306,7 +306,7 @@ export class AINativeBrowserContribution ComponentRegistryImpl.addLayoutModule(this.appConfig.layoutConfig, DESIGN_MENU_BAR_RIGHT, AI_CHAT_LOGO_AVATAR_ID); this.chatProxyService.registerDefaultAgent(); this.chatInternalService.init(); - await this.chatManagerService.init(); + this.chatManagerService.init(); } } diff --git a/packages/ai-native/src/browser/chat/acp-chat-agent.ts b/packages/ai-native/src/browser/chat/acp-chat-agent.ts new file mode 100644 index 0000000000..8f653175cd --- /dev/null +++ b/packages/ai-native/src/browser/chat/acp-chat-agent.ts @@ -0,0 +1,192 @@ +import { Autowired, Injectable } from '@opensumi/di'; +import { PreferenceService } from '@opensumi/ide-core-browser'; +import { + AIBackSerivcePath, + CancellationToken, + DEFAULT_AGENT_TYPE, + Deferred, + IAIBackService, + IAIReporter, + IApplicationService, + IChatProgress, + MCPConfigServiceToken, + URI, +} from '@opensumi/ide-core-common'; +import { AINativeSettingSectionsId } from '@opensumi/ide-core-common/lib/settings/ai-native'; +import { MonacoCommandRegistry } from '@opensumi/ide-editor/lib/browser/monaco-contrib/command/command.service'; +import { IMessageService } from '@opensumi/ide-overlay'; +import { listenReadable } from '@opensumi/ide-utils/lib/stream'; +import { IWorkspaceService } from '@opensumi/ide-workspace'; + +import { + CoreMessage, + IChatAgent, + IChatAgentCommand, + IChatAgentMetadata, + IChatAgentRequest, + IChatAgentResult, + IChatAgentService, + IChatAgentWelcomeMessage, +} from '../../common/index'; +import { MCPConfigService } from '../mcp/config/mcp-config.service'; + +import { ChatFeatureRegistry } from './chat.feature.registry'; + +/** + * ACP Chat Agent - 实现默认的聊天代理 + */ +@Injectable() +export class AcpChatAgent implements IChatAgent { + static readonly AGENT_ID = 'Default_Chat_Agent'; + + @Autowired(IChatAgentService) + private readonly chatAgentService: IChatAgentService; + + @Autowired(AIBackSerivcePath) + private readonly aiBackService: IAIBackService; + + @Autowired(PreferenceService) + private readonly preferenceService: PreferenceService; + + @Autowired(IApplicationService) + private readonly applicationService: IApplicationService; + + @Autowired(MonacoCommandRegistry) + private readonly monacoCommandRegistry: MonacoCommandRegistry; + + @Autowired(ChatFeatureRegistry) + private readonly chatFeatureRegistry: ChatFeatureRegistry; + + @Autowired(IAIReporter) + private readonly aiReporter: IAIReporter; + + @Autowired(IMessageService) + private readonly messageService: IMessageService; + + @Autowired(MCPConfigServiceToken) + private readonly mcpConfigService: MCPConfigService; + + @Autowired(IWorkspaceService) + private readonly workspaceService: IWorkspaceService; + + public id = AcpChatAgent.AGENT_ID; + + public get metadata(): IChatAgentMetadata { + return { + systemPrompt: this.preferenceService.get(AINativeSettingSectionsId.SystemPrompt), + }; + } + + public set metadata(_) { + // 不处理 + } + + private async getRequestOptions() { + const model = this.preferenceService.get(AINativeSettingSectionsId.LLMModelSelection); + const modelId = this.preferenceService.get(AINativeSettingSectionsId.ModelID); + let apiKey: string = ''; + let baseURL: string = ''; + if (model === 'deepseek') { + apiKey = this.preferenceService.get(AINativeSettingSectionsId.DeepseekApiKey, ''); + } else if (model === 'openai') { + apiKey = this.preferenceService.get(AINativeSettingSectionsId.OpenaiApiKey, ''); + } else if (model === 'anthropic') { + apiKey = this.preferenceService.get(AINativeSettingSectionsId.AnthropicApiKey, ''); + } else { + // openai-compatible 为兜底 + apiKey = this.preferenceService.get(AINativeSettingSectionsId.OpenaiApiKey, ''); + baseURL = this.preferenceService.get(AINativeSettingSectionsId.OpenaiBaseURL, ''); + } + const maxTokens = this.preferenceService.get(AINativeSettingSectionsId.MaxTokens); + const agent = this.chatAgentService.getAgent(AcpChatAgent.AGENT_ID); + const disabledTools = await this.mcpConfigService.getDisabledTools(); + + return { + clientId: this.applicationService.clientId, + model, + modelId, + apiKey, + baseURL, + maxTokens, + system: agent?.metadata.systemPrompt, + disabledTools, + }; + } + + async invoke( + request: IChatAgentRequest, + progress: (part: IChatProgress) => void, + history: CoreMessage[], + token: CancellationToken, + ): Promise { + const chatDeferred = new Deferred(); + const { message, command } = request; + let prompt: string = message; + if (command) { + const commandHandler = this.chatFeatureRegistry.getSlashCommandHandler(command); + if (commandHandler && commandHandler.providerPrompt) { + const editor = this.monacoCommandRegistry.getActiveCodeEditor(); + const slashCommandPrompt = await commandHandler.providerPrompt(message, editor); + prompt = slashCommandPrompt; + } + } + + let sessionId = request.sessionId; + // 去掉 acp: 前缀(Agent 使用纯 UUID) + if (sessionId.startsWith('acp:')) { + // 【优化】等待后台 ACP Session 初始化完成 + // createSession 时已经异步初始化,正常情况下应该立即可用 + sessionId = sessionId.substring(4); + } + // agent 模式只需要发送最后一条数据 + const lastmessage = history[history.length - 1]; + + await this.workspaceService.whenReady; + const stream = await this.aiBackService.requestStream( + prompt, + { + requestId: request.requestId, + sessionId, + history: [lastmessage], + images: request.images, + ...(await this.getRequestOptions()), + agentSessionConfig: { + agentType: DEFAULT_AGENT_TYPE, + workspaceDir: new URI(this.workspaceService.workspace?.uri).codeUri.fsPath, + }, + }, + token, + ); + + listenReadable(stream, { + onData: (data) => { + progress(data); + }, + onEnd: () => { + chatDeferred.resolve(); + }, + onError: (error) => { + this.messageService.error(error.message); + this.aiReporter.end(sessionId + '_' + request.requestId, { + message: error.message, + success: false, + command, + }); + chatDeferred.reject(error); + }, + }); + + await chatDeferred.promise; + return {}; + } + + async provideSlashCommands(): Promise { + return this.chatFeatureRegistry + .getAllSlashCommand() + .map((s) => ({ ...s, name: s.name, description: s.description || '' })); + } + + async provideChatWelcomeMessage(): Promise { + return undefined; + } +} diff --git a/packages/ai-native/src/browser/chat/acp-session-provider.ts b/packages/ai-native/src/browser/chat/acp-session-provider.ts new file mode 100644 index 0000000000..978e85397c --- /dev/null +++ b/packages/ai-native/src/browser/chat/acp-session-provider.ts @@ -0,0 +1,197 @@ +import { Autowired, Injectable } from '@opensumi/di'; +import { + AIBackSerivcePath, + AgentProcessConfig, + DEFAULT_AGENT_TYPE, + Domain, + IAIBackService, + URI, +} from '@opensumi/ide-core-common'; +import { IWorkspaceService } from '@opensumi/ide-workspace'; + +import { ISessionModel, ISessionProvider, SessionProviderDomain } from './session-provider'; + +/** + * ACP Session Provider + * 通过 RPC 调用 Node 层加载 ACP Agent 的 Session + */ +@Domain(SessionProviderDomain) +@Injectable() +export class ACPSessionProvider implements ISessionProvider { + readonly id = 'ACPSessionProvider'; + + @Autowired(AIBackSerivcePath) + private aiBackService: IAIBackService; + + @Autowired(IWorkspaceService) + private workspaceService: IWorkspaceService; + + private loadedSessionMap: Map = new Map(); + + private loadedSessionsResult: ISessionModel[] | null = null; + + canHandle(mode: string): boolean { + return mode.startsWith('acp'); + } + + async createSession(title?: string): Promise { + if (!this.aiBackService?.createSession) { + throw new Error('aiBackService.createSession is not available'); + } + + await this.workspaceService.whenReady; + const result = await this.aiBackService.createSession({ + workspaceDir: new URI(this.workspaceService.workspace?.uri).codeUri.fsPath, + agentType: DEFAULT_AGENT_TYPE, + }); + + if (!result?.sessionId) { + throw new Error('createSession did not return a valid sessionId'); + } + + // 构造本地 Session ID(添加 acp: 前缀) + const sessionId = `acp:${result.sessionId}`; + + // 构造空壳会话模型 + const sessionModel: ISessionModel = { + sessionId, + history: { + additional: {}, + messages: [], + }, + requests: [], + title: title || '', + }; + + // 新创建的 Session 不需要 load,直接加入缓存 + this.loadedSessionMap.set(sessionId, sessionModel); + + return sessionModel; + } + + async loadSessions(): Promise { + if (this.loadedSessionsResult) { + return this.loadedSessionsResult; + } + + if (!this.aiBackService?.listSessions) { + return []; + } + + await this.workspaceService.whenReady; + const result = await this.aiBackService.listSessions({ + workspaceDir: new URI(this.workspaceService.workspace?.uri).codeUri.fsPath, + agentType: DEFAULT_AGENT_TYPE, + }); + + if (!result?.sessions?.length) { + return []; + } + + // 只返回会话列表的元数据,不加载完整数据 + // 完整数据在 getSession 时通过 loadSession 按需加载 + const sessionModels = result.sessions + .slice(0, 20) + .reverse() + .map((sessionMeta) => ({ + ...sessionMeta, + sessionId: `acp:${sessionMeta.sessionId}`, + history: { + additional: {}, + messages: [], + }, + requests: [], + title: sessionMeta.title, + })); + + this.loadedSessionsResult = sessionModels as unknown as ISessionModel[]; + + return this.loadedSessionsResult; + } + + async loadSession(sessionId: string): Promise { + if (!sessionId) { + return undefined; + } + + // 检查缓存,避免重复加载 + const cachedSession = this.loadedSessionMap.get(sessionId); + if (cachedSession) { + return cachedSession; + } + + if (!this.aiBackService?.loadAgentSession) { + return undefined; + } + + // 解析 sessionId,提取 agentSessionId(去掉 'acp:' 前缀) + const agentSessionId = sessionId.startsWith('acp:') ? sessionId.slice(4) : sessionId; + + try { + // 构造 AgentProcessConfig + const config: AgentProcessConfig = { + agentType: DEFAULT_AGENT_TYPE, + workspaceDir: new URI(this.workspaceService.workspace?.uri).codeUri.fsPath, + }; + + const agentSession = await this.aiBackService.loadAgentSession(config, agentSessionId); + + if (!agentSession) { + return undefined; + } + + // 将 Agent Session 转换为 ISessionModel 格式 + const sessionModel = this.convertAgentSessionToModel(sessionId, agentSession); + + // 缓存加载的 Session + this.loadedSessionMap.set(sessionId, sessionModel); + + return sessionModel; + } catch (error) { + return undefined; + } + } + + private convertAgentSessionToModel( + sessionId: string, + agentSession: { + sessionId: string; + messages: Array<{ + role: 'user' | 'assistant'; + content: string; + timestamp?: number; + }>; + }, + ): ISessionModel { + // 过滤掉包含 的系统消息 + const filteredMessages = agentSession.messages.filter((msg, index) => { + // 如果内容包含系统命令的 XML 标签,则过滤掉 + if (msg.content.includes('') || msg.content.includes('')) { + return false; + } + return true; + }); + + // 转换消息格式 + const messages = filteredMessages.map((msg, index) => ({ + id: `${sessionId}-msg-${index}`, + role: msg.role === 'user' ? 1 : 2, // ChatMessageRole.User = 1, Assistant = 2 + content: msg.content, + order: index, + timestamp: msg.timestamp, + })); + + const result = { + sessionId, + history: { + additional: {}, + messages, + }, + requests: [], + }; + + return result; + } + + async saveSessions(sessions: ISessionModel[]): Promise {} +} diff --git a/packages/ai-native/src/browser/chat/apply.service.ts b/packages/ai-native/src/browser/chat/apply.service.ts index dff3bccef0..2c6977ae30 100644 --- a/packages/ai-native/src/browser/chat/apply.service.ts +++ b/packages/ai-native/src/browser/chat/apply.service.ts @@ -1,3 +1,15 @@ +/** + * ApplyService - 代码应用服务 + * + * 负责将 AI 生成的代码应用到实际文件中: + * - 继承 BaseApplyService 提供基础应用能力 + * - 支持代码块应用后的自动修复(调用 Code Action) + * - 通过 AI 后端服务合并代码更新 + * + * 被以下类调用: + * - ChatEditSchemeDocumentProvider: 依赖注入使用,用于获取代码块内容 + * - ChatMultiDiffResolver: 依赖注入使用,用于获取会话代码块 + */ import { Autowired, Injectable } from '@opensumi/di'; import { AIBackSerivcePath, diff --git a/packages/ai-native/src/browser/chat/chat-agent.service.ts b/packages/ai-native/src/browser/chat/chat-agent.service.ts index 3eaede5263..a952fbc477 100644 --- a/packages/ai-native/src/browser/chat/chat-agent.service.ts +++ b/packages/ai-native/src/browser/chat/chat-agent.service.ts @@ -1,3 +1,17 @@ +/** + * ChatAgentService - AI 聊天 Agent 服务 + * + * 负责管理 AI 聊天 Agent 的注册和调用,包括: + * - 注册和管理多个聊天 Agent + * - 调用 Agent 处理聊天请求 + * - 提供上下文消息增强 + * - 获取 Followups 和示例问题 + * + * 被以下类调用: + * - ChatManagerService: 依赖注入使用,用于调用 Agent 处理聊天请求 + * - ChatProxyService: 注册默认 Agent + * - ChatAgentViewService: 获取已注册的 Agent 列表 + */ import flatMap from 'lodash/flatMap'; import { Autowired, Injectable } from '@opensumi/di'; @@ -145,7 +159,7 @@ export class ChatAgentService extends Disposable implements IChatAgentService { ): Promise { const data = this.agents.get(id); if (!data) { - throw new Error(`No agent with id ${id}`); + throw new Error(`No agent with id ${id},this.agents ${this.agents}`); } // 发送第一条消息时携带初始 context diff --git a/packages/ai-native/src/browser/chat/chat-agent.view.service.ts b/packages/ai-native/src/browser/chat/chat-agent.view.service.ts index 76cdbb3670..c3f839fe12 100644 --- a/packages/ai-native/src/browser/chat/chat-agent.view.service.ts +++ b/packages/ai-native/src/browser/chat/chat-agent.view.service.ts @@ -1,3 +1,15 @@ +/** + * ChatAgentViewService - 聊天 Agent 视图服务 + * + * 负责管理聊天视图中的组件渲染和 Agent 展示: + * - 注册和管理聊天组件配置 + * - 提供组件配置的延迟加载支持 + * - 获取可渲染的 Agent 列表 + * + * 被以下类调用: + * - ChatProxyService: 注册聊天组件 + * - ChatView (chat.view.tsx): 获取组件配置和渲染 Agent + */ import { Autowired, Injectable } from '@opensumi/di'; import { Deferred, IDisposable } from '@opensumi/ide-core-common'; diff --git a/packages/ai-native/src/browser/chat/chat-edit-resource.ts b/packages/ai-native/src/browser/chat/chat-edit-resource.ts index 6e0ea63a26..0eaaab60be 100644 --- a/packages/ai-native/src/browser/chat/chat-edit-resource.ts +++ b/packages/ai-native/src/browser/chat/chat-edit-resource.ts @@ -1,3 +1,14 @@ +/** + * ChatEditSchemeDocumentProvider - 聊天编辑方案文档提供者 + * + * 负责提供聊天编辑功能的文档内容: + * - 处理特定 scheme 的文档内容请求 + * - 从 BaseApplyService 获取代码块的原始或更新后内容 + * - 提供只读文档模型 + * + * 被以下类调用: + * - 由 IDE 编辑器系统通过 IEditorDocumentModelContentProvider 接口调用 + */ import { Autowired, Injectable } from '@opensumi/di'; import { AppConfig, Emitter, Event, IApplicationService, PreferenceService, URI } from '@opensumi/ide-core-browser'; import { WorkbenchEditorService } from '@opensumi/ide-editor'; diff --git a/packages/ai-native/src/browser/chat/chat-manager.service.ts b/packages/ai-native/src/browser/chat/chat-manager.service.ts index 85a6599dab..0f4c031861 100644 --- a/packages/ai-native/src/browser/chat/chat-manager.service.ts +++ b/packages/ai-native/src/browser/chat/chat-manager.service.ts @@ -1,5 +1,16 @@ +/** + * ChatManagerService - 聊天会话管理器服务 + * + * 负责管理 AI 聊天的会话生命周期,包括: + * - 创建、获取、清除聊天会话 + * - 管理聊天请求的发送和取消 + * - 持久化会话历史到存储 + * + * 被以下类调用: + * - ChatInternalService: 依赖注入使用,用于会话管理操作 + */ import { Autowired, INJECTOR_TOKEN, Injectable, Injector } from '@opensumi/di'; -import { PreferenceService } from '@opensumi/ide-core-browser'; +import { AINativeConfigService, PreferenceService } from '@opensumi/ide-core-browser'; import { AINativeSettingSectionsId, CancellationToken, @@ -9,37 +20,18 @@ import { Emitter, IChatProgress, IDisposable, - IStorage, LRUCache, - STORAGE_NAMESPACE, - StorageProvider, debounce, } from '@opensumi/ide-core-common'; -import { ChatFeatureRegistryToken, IHistoryChatMessage } from '@opensumi/ide-core-common/lib/types/ai-native'; +import { ChatFeatureRegistryToken } from '@opensumi/ide-core-common/lib/types/ai-native'; -import { IChatAgentService, IChatFollowup, IChatRequestMessage, IChatResponseErrorDetails } from '../../common'; +import { IChatAgentService } from '../../common'; import { MsgHistoryManager } from '../model/msg-history-manager'; -import { ChatModel, ChatRequestModel, ChatResponseModel, IChatProgressResponseContent } from './chat-model'; +import { ChatModel, ChatRequestModel, ChatResponseModel } from './chat-model'; import { ChatFeatureRegistry } from './chat.feature.registry'; - -interface ISessionModel { - sessionId: string; - modelId: string; - history: { additional: Record; messages: IHistoryChatMessage[] }; - requests: { - requestId: string; - message: IChatRequestMessage; - response: { - isCanceled: boolean; - responseText: string; - responseContents: IChatProgressResponseContent[]; - responseParts: IChatProgressResponseContent[]; - errorDetails: IChatResponseErrorDetails | undefined; - followups: IChatFollowup[]; - }; - }[]; -} +import { ISessionModel, ISessionProvider } from './session-provider'; +import { ISessionProviderRegistry } from './session-provider-registry'; const MAX_SESSION_COUNT = 20; @@ -67,14 +59,17 @@ export class ChatManagerService extends Disposable { private storageInitEmitter = new Emitter(); public onStorageInit = this.storageInitEmitter.event; + @Autowired(AINativeConfigService) + protected readonly aiNativeConfig: AINativeConfigService; + @Autowired(INJECTOR_TOKEN) injector: Injector; @Autowired(IChatAgentService) chatAgentService: IChatAgentService; - @Autowired(StorageProvider) - private storageProvider: StorageProvider; + @Autowired(ISessionProviderRegistry) + private sessionProviderRegistry: ISessionProviderRegistry; @Autowired(PreferenceService) private preferenceService: PreferenceService; @@ -82,20 +77,18 @@ export class ChatManagerService extends Disposable { @Autowired(ChatFeatureRegistryToken) private chatFeatureRegistry: ChatFeatureRegistry; - private _chatStorage: IStorage; + private mainProvider: ISessionProvider | null = null; protected fromJSON(data: ISessionModel[]) { return data - .filter((item) => item.history.messages.length > 0) + .filter((item) => item.history.messages.length > 0 || item.sessionId.startsWith('acp:')) .map((item) => { - const model = new ChatModel( - this.chatFeatureRegistry, - { - sessionId: item.sessionId, - history: new MsgHistoryManager(this.chatFeatureRegistry, item.history), - modelId: item.modelId, - }, - ); + const model = new ChatModel(this.chatFeatureRegistry, { + sessionId: item.sessionId, + history: new MsgHistoryManager(this.chatFeatureRegistry, item.history), + modelId: item.modelId, + title: item?.title, + }); const requests = item.requests.map( (request) => new ChatRequestModel( @@ -118,31 +111,93 @@ export class ChatManagerService extends Disposable { }); } + /** + * 将 ChatModel 转换为 ISessionModel 数据 + */ + private toSessionData(model: ChatModel): ISessionModel { + return { + sessionId: model.sessionId, + modelId: model.modelId, + history: model.history.toJSON(), + requests: model.getRequests().map((request) => ({ + requestId: request.requestId, + message: request.message, + response: { + isCanceled: request.response.isCanceled, + responseText: request.response.responseText, + responseContents: request.response.responseContents, + responseParts: request.response.responseParts, + errorDetails: request.response.errorDetails, + followups: request.response.followups, + }, + })), + }; + } + constructor() { super(); + const mode = this.aiNativeConfig.capabilities.supportsAgentMode ? 'acp' : 'local'; // TODO 写死, 按需切换 + + const allProviders = this.sessionProviderRegistry.getAllProviders(); + + const p = allProviders.filter((provider) => provider.canHandle(mode))[0]; + + this.mainProvider = p; } async init() { - this._chatStorage = await this.storageProvider(STORAGE_NAMESPACE.CHAT); - const sessionsModelData = this._chatStorage.get('sessionModels', []); - const savedSessions = this.fromJSON(sessionsModelData); - savedSessions.forEach((session) => { - this.#sessionModels.set(session.sessionId, session); - this.listenSession(session); - }); - await this.storageInitEmitter.fireAndAwait(); + try { + if (!this.mainProvider) { + await this.storageInitEmitter.fireAndAwait(); + return; + } + // acp模式只会先拉取列表,具体的Session需要单独的load + const sessionsModelData = await this.mainProvider.loadSessions(); + + // 只保留最新的 20 个会话 + const recentSessionsData = sessionsModelData.slice(-MAX_SESSION_COUNT); + + const savedSessions = this.fromJSON(recentSessionsData); + + savedSessions.forEach((session) => { + this.#sessionModels.set(session.sessionId, session); + }); + + await this.storageInitEmitter.fireAndAwait(); + } catch (error) { + await this.storageInitEmitter.fireAndAwait(); + } } getSessions() { - return Array.from(this.#sessionModels.values()); + const sessions = Array.from(this.#sessionModels.values()); + + return sessions; } - startSession() { - const model = new ChatModel( - this.chatFeatureRegistry, - ); + /** + * 启动新会话 + * - ACP 模式:调用 Provider.createSession 创建远程会话 + * - Local 模式:创建本地会话 + */ + async startSession(): Promise { + if (this.aiNativeConfig.capabilities.supportsAgentMode && this.mainProvider?.createSession) { + const sessionData = await this.mainProvider.createSession(); + const models = this.fromJSON([sessionData]); + if (models.length > 0) { + const model = models[0]; + this.#sessionModels.set(model.sessionId, model); + this.listenSession(model); + + return model; + } + } + + // Local 模式:创建本地会话 + const model = new ChatModel(this.chatFeatureRegistry); this.#sessionModels.set(model.sessionId, model); this.listenSession(model); + return model; } @@ -150,6 +205,35 @@ export class ChatManagerService extends Disposable { return this.#sessionModels.get(sessionId); } + /** + * 加载指定会话 + * @param sessionId 本地 Session ID + * @returns Session 数据,不存在时返回 undefined + */ + async loadSession(sessionId: string) { + if (this.aiNativeConfig.capabilities.supportsAgentMode) { + // 如果是acp模式,会从provider的loadSession(sessionId)加载指定的会话 + const existingSession = this.#sessionModels.get(sessionId); + if (existingSession?.history?.getMessages()?.length) { + return; + } + + // 从provider加载指定会话 + if (this.mainProvider?.loadSession && sessionId) { + return this.mainProvider.loadSession(sessionId).then((sessionData) => { + if (sessionData) { + const sessions = this.fromJSON([sessionData]); + if (sessions.length > 0) { + const session = sessions[0]; + this.#sessionModels.set(sessionId, session); + this.listenSession(session); + } + } + }); + } + } + } + clearSession(sessionId: string) { const model = this.#sessionModels.get(sessionId) as ChatModel; if (!model) { @@ -198,7 +282,7 @@ export class ChatManagerService extends Disposable { }); const contextWindow = this.preferenceService.get(AINativeSettingSectionsId.ContextWindow); - const history = model.getMessageHistory(contextWindow); + const history = typeof contextWindow === 'number' ? model.getMessageHistory(contextWindow) : []; try { const progressCallback = (progress: IChatProgress) => { @@ -253,8 +337,12 @@ export class ChatManagerService extends Disposable { } @debounce(1000) - protected saveSessions() { - this._chatStorage.set('sessionModels', this.getSessions()); + protected async saveSessions() { + if (!this.mainProvider?.saveSessions) { + return; + } + const sessionsData = this.getSessions().map((model) => this.toSessionData(model)); + await this.mainProvider.saveSessions(sessionsData); } cancelRequest(sessionId: string) { diff --git a/packages/ai-native/src/browser/chat/chat-model.ts b/packages/ai-native/src/browser/chat/chat-model.ts index a365010f44..1721a3da71 100644 --- a/packages/ai-native/src/browser/chat/chat-model.ts +++ b/packages/ai-native/src/browser/chat/chat-model.ts @@ -1,4 +1,18 @@ -/* eslint-disable no-console */ +/** + * ChatModel - 聊天数据模型 + * + * 定义了聊天会话、请求、响应的数据模型: + * - ChatModel: 表示一个聊天会话,管理会话 ID、历史消息和请求列表 + * - ChatRequestModel: 表示一次聊天请求,包含请求消息和响应 + * - ChatResponseModel: 表示聊天响应,管理响应内容、状态和错误信息 + * - ChatWelcomeMessageModel: 表示欢迎消息和示例问题 + * - ChatSlashCommandItemModel: 表示斜杠命令项 + * + * 被以下类调用: + * - ChatManagerService: 创建和管理会话模型 + * - ChatFeatureRegistry: 创建欢迎消息和命令项模型 + * - ChatInternalService: 使用会话模型进行会话管理 + */ import { Injectable } from '@opensumi/di'; import { Disposable, @@ -300,12 +314,18 @@ export class ChatModel extends Disposable implements IChatModel { constructor( private chatFeatureRegistry: ChatFeatureRegistry, - initParams?: { sessionId?: string; history?: MsgHistoryManager; modelId?: string }, + initParams?: { sessionId?: string; history?: MsgHistoryManager; modelId?: string; title?: string }, ) { super(); this.#sessionId = initParams?.sessionId ?? uuid(); this.history = initParams?.history ?? new MsgHistoryManager(this.chatFeatureRegistry); this.#modelId = initParams?.modelId; + this.#title = initParams?.title ?? ''; + } + + #title: string; + get title(): string { + return this.#title; } #sessionId: string; @@ -415,7 +435,6 @@ export class ChatModel extends Disposable implements IChatModel { try { return JSON.parse(jsonString); } catch (e) { - console.error(`[ChatModel] Failed to parse ${context}:`, e); return {}; } } @@ -502,13 +521,16 @@ export class ChatModel extends Disposable implements IChatModel { if (basicKind.includes(kind)) { request.response.updateContent(progress, quiet); } else { - console.error(`Couldn't handle progress: ${JSON.stringify(progress)}`); + // Couldn't handle progress } } getRequest(requestId: string): ChatRequestModel | undefined { return this.#requests.get(requestId); } + getRequests(): ChatRequestModel[] { + return Array.from(this.#requests.values()); + } override dispose(): void { super.dispose(); diff --git a/packages/ai-native/src/browser/chat/chat-multi-diff-source.ts b/packages/ai-native/src/browser/chat/chat-multi-diff-source.ts index ac7d908df9..1188025af1 100644 --- a/packages/ai-native/src/browser/chat/chat-multi-diff-source.ts +++ b/packages/ai-native/src/browser/chat/chat-multi-diff-source.ts @@ -1,3 +1,14 @@ +/** + * ChatMultiDiffResolver / ChatMultiDiffSource - 聊天多路差异解析器 + * + * 负责解析和提供聊天编辑功能的多路差异对比源: + * - ChatMultiDiffResolver: 解析特定 scheme 的 URI 为多路差异源 + * - ChatMultiDiffSource: 提供差异对比所需的文件资源列表 + * - 支持多文件差异对比视图 + * + * 被以下类调用: + * - 由 IDE 多路差异编辑器系统通过 IMultiDiffSourceResolver 接口调用 + */ import { Autowired, Injectable } from '@opensumi/di'; import { AppConfig, Event, URI, path } from '@opensumi/ide-core-browser'; import { diff --git a/packages/ai-native/src/browser/chat/chat-proxy.service.ts b/packages/ai-native/src/browser/chat/chat-proxy.service.ts index 6c131fd445..67db9a5e58 100644 --- a/packages/ai-native/src/browser/chat/chat-proxy.service.ts +++ b/packages/ai-native/src/browser/chat/chat-proxy.service.ts @@ -1,61 +1,43 @@ +/** + * ChatProxyService - 聊天代理服务 + * + * 负责注册默认的聊天 Agent,作为 AI 后端服务和聊天界面之间的代理: + * - 注册默认 Agent 处理聊天请求 + * - 调用 AI 后端服务进行流式请求 + * - 管理请求配置(模型、API Key、系统提示等) + * + * 被以下类调用: + * - ChatFeatureRegistry: 使用 AGENT_ID 注册斜杠命令 + * - ChatAgentViewService: 过滤渲染 Agent 时排除默认 Agent + * - ApplyService: 依赖注入使用,获取请求配置 + */ import { Autowired, Injectable } from '@opensumi/di'; import { PreferenceService } from '@opensumi/ide-core-browser'; import { - AIBackSerivcePath, - CancellationToken, ChatAgentViewServiceToken, - ChatFeatureRegistryToken, - Deferred, Disposable, - IAIBackService, - IAIReporter, IApplicationService, - IChatProgress, MCPConfigServiceToken, } from '@opensumi/ide-core-common'; import { AINativeSettingSectionsId } from '@opensumi/ide-core-common/lib/settings/ai-native'; -import { MonacoCommandRegistry } from '@opensumi/ide-editor/lib/browser/monaco-contrib/command/command.service'; -import { IMessageService } from '@opensumi/ide-overlay'; -import { listenReadable } from '@opensumi/ide-utils/lib/stream'; -import { - CoreMessage, - IChatAgentCommand, - IChatAgentRequest, - IChatAgentResult, - IChatAgentService, - IChatAgentWelcomeMessage, -} from '../../common'; -import { DEFAULT_SYSTEM_PROMPT } from '../../common/prompts/system-prompt'; +import { DefaultChatAgentToken, IChatAgentService } from '../../common'; import { ChatToolRender } from '../components/ChatToolRender'; import { MCPConfigService } from '../mcp/config/mcp-config.service'; import { IChatAgentViewService } from '../types'; -import { ChatFeatureRegistry } from './chat.feature.registry'; +import { DefaultChatAgent } from './default-chat-agent'; /** * @internal */ @Injectable() export class ChatProxyService extends Disposable { - // 避免和插件注册的 agent id 冲突 - static readonly AGENT_ID = 'Default_Chat_Agent'; + static readonly AGENT_ID = DefaultChatAgent.AGENT_ID; @Autowired(IChatAgentService) private readonly chatAgentService: IChatAgentService; - @Autowired(AIBackSerivcePath) - private readonly aiBackService: IAIBackService; - - @Autowired(ChatFeatureRegistryToken) - private readonly chatFeatureRegistry: ChatFeatureRegistry; - - @Autowired(MonacoCommandRegistry) - private readonly monacoCommandRegistry: MonacoCommandRegistry; - - @Autowired(IAIReporter) - private readonly aiReporter: IAIReporter; - @Autowired(ChatAgentViewServiceToken) private readonly chatAgentViewService: IChatAgentViewService; @@ -65,13 +47,26 @@ export class ChatProxyService extends Disposable { @Autowired(IApplicationService) private readonly applicationService: IApplicationService; - @Autowired(IMessageService) - private readonly messageService: IMessageService; - @Autowired(MCPConfigServiceToken) private readonly mcpConfigService: MCPConfigService; - private chatDeferred: Deferred = new Deferred(); + @Autowired(DefaultChatAgentToken) + private readonly defaultChatAgent: DefaultChatAgent; + + public registerDefaultAgent() { + this.chatAgentViewService.registerChatComponent({ + id: 'toolCall', + component: ChatToolRender, + initialProps: {}, + }); + + this.applicationService.getBackendOS().then(() => { + this.addDispose(this.chatAgentService.registerAgent(this.defaultChatAgent)); + queueMicrotask(() => { + this.chatAgentService.updateAgent(ChatProxyService.AGENT_ID, {}); + }); + }); + } public async getRequestOptions() { const model = this.preferenceService.get(AINativeSettingSectionsId.LLMModelSelection); @@ -90,7 +85,7 @@ export class ChatProxyService extends Disposable { baseURL = this.preferenceService.get(AINativeSettingSectionsId.OpenaiBaseURL, ''); } const maxTokens = this.preferenceService.get(AINativeSettingSectionsId.MaxTokens); - const agent = this.chatAgentService.getAgent(ChatProxyService.AGENT_ID); + const agent = this.chatAgentService.getAgent(DefaultChatAgent.AGENT_ID); const disabledTools = await this.mcpConfigService.getDisabledTools(); return { clientId: this.applicationService.clientId, @@ -103,85 +98,4 @@ export class ChatProxyService extends Disposable { disabledTools, }; } - - public registerDefaultAgent() { - this.chatAgentViewService.registerChatComponent({ - id: 'toolCall', - component: ChatToolRender, - initialProps: {}, - }); - - this.applicationService.getBackendOS().then(() => { - this.addDispose( - this.chatAgentService.registerAgent({ - id: ChatProxyService.AGENT_ID, - metadata: { - systemPrompt: this.preferenceService.get( - AINativeSettingSectionsId.SystemPrompt, - DEFAULT_SYSTEM_PROMPT, - ), - }, - invoke: async ( - request: IChatAgentRequest, - progress: (part: IChatProgress) => void, - history: CoreMessage[], - token: CancellationToken, - ): Promise => { - this.chatDeferred = new Deferred(); - const { message, command } = request; - let prompt: string = message; - if (command) { - const commandHandler = this.chatFeatureRegistry.getSlashCommandHandler(command); - if (commandHandler && commandHandler.providerPrompt) { - const editor = this.monacoCommandRegistry.getActiveCodeEditor(); - const slashCommandPrompt = await commandHandler.providerPrompt(message, editor); - prompt = slashCommandPrompt; - } - } - - const stream = await this.aiBackService.requestStream( - prompt, - { - requestId: request.requestId, - sessionId: request.sessionId, - history, - images: request.images, - ...(await this.getRequestOptions()), - }, - token, - ); - - listenReadable(stream, { - onData: (data) => { - progress(data); - }, - onEnd: () => { - this.chatDeferred.resolve(); - }, - onError: (error) => { - this.messageService.error(error.message); - this.aiReporter.end(request.sessionId + '_' + request.requestId, { - message: error.message, - success: false, - command, - }); - }, - }); - - await this.chatDeferred.promise; - return {}; - }, - provideSlashCommands: async (): Promise => - this.chatFeatureRegistry - .getAllSlashCommand() - .map((s) => ({ ...s, name: s.name, description: s.description || '' })), - provideChatWelcomeMessage: async (): Promise => undefined, - }), - ); - }); - - queueMicrotask(() => { - this.chatAgentService.updateAgent(ChatProxyService.AGENT_ID, {}); - }); - } } diff --git a/packages/ai-native/src/browser/chat/chat.api.service.ts b/packages/ai-native/src/browser/chat/chat.api.service.ts index a13091fde4..f344c20835 100644 --- a/packages/ai-native/src/browser/chat/chat.api.service.ts +++ b/packages/ai-native/src/browser/chat/chat.api.service.ts @@ -1,3 +1,16 @@ +/** + * ChatService - 聊天 API 服务 + * + * 提供聊天功能的外部调用接口,负责消息发送和视图控制: + * - 显示聊天视图 + * - 发送用户消息和 AI 回复消息 + * - 管理消息列表和滚动行为 + * - 清除历史消息 + * + * 被以下类调用: + * - ChatAgentService: 填充聊天输入 + * - 外部模块:通过 ChatServiceToken 注入使用 + */ import { Autowired, Injectable } from '@opensumi/di'; import { Disposable, Emitter, Event } from '@opensumi/ide-core-common'; import { IChatComponent, IChatContent } from '@opensumi/ide-core-common/lib/types/ai-native'; diff --git a/packages/ai-native/src/browser/chat/chat.feature.registry.ts b/packages/ai-native/src/browser/chat/chat.feature.registry.ts index 95319ff8f4..7ff0a7814d 100644 --- a/packages/ai-native/src/browser/chat/chat.feature.registry.ts +++ b/packages/ai-native/src/browser/chat/chat.feature.registry.ts @@ -1,3 +1,19 @@ +/** + * ChatFeatureRegistry - 聊天功能注册器 + * + * 负责管理聊天功能的注册和查询: + * - 注册和管理斜杠命令及其处理器 + * - 注册欢迎内容和示例问题 + * - 注册图片上传提供者和消息总结提供者 + * - 解析斜杠命令 + * + * 被以下类调用: + * - ChatModel: 创建欢迎消息和命令项模型 + * - ChatManagerService: 依赖注入使用 + * - ChatAgentService: 依赖注入使用 + * - ChatProxyService: 注册斜杠命令 + * - ChatAgentViewService: 注册欢迎消息 + */ import { Injectable } from '@opensumi/di'; import { Disposable, Emitter, Event, getDebugLogger } from '@opensumi/ide-core-common'; diff --git a/packages/ai-native/src/browser/chat/chat.internal.service.ts b/packages/ai-native/src/browser/chat/chat.internal.service.ts index d8196dea6c..0351ec60e3 100644 --- a/packages/ai-native/src/browser/chat/chat.internal.service.ts +++ b/packages/ai-native/src/browser/chat/chat.internal.service.ts @@ -1,3 +1,17 @@ +/** + * ChatInternalService - 聊天内部服务 + * + * 负责聊天功能的内部状态管理和事件控制: + * - 管理当前会话模型 + * - 创建和管理请求 + * - 发送和取消请求 + * - 管理会话生命周期(创建、清除、激活) + * - 提供事件通知(Request 变化、Session 变化、取消、重新生成等) + * + * 被以下类调用: + * - ChatService: 依赖注入使用,用于访问 sessionModel + * - ChatView (chat.view.tsx): 依赖注入使用,用于会话管理和事件订阅 + */ import { Autowired, Injectable } from '@opensumi/di'; import { PreferenceService } from '@opensumi/ide-core-browser'; import { AIBackSerivcePath, Disposable, Emitter, Event, IAIBackService } from '@opensumi/ide-core-common'; @@ -36,6 +50,14 @@ export class ChatInternalService extends Disposable { private readonly _onRegenerateRequest = new Emitter(); public readonly onRegenerateRequest: Event = this._onRegenerateRequest.event; + /** 当 Agent 模式切换成功时触发,payload 为新的 modeId */ + private readonly _onModeChange = new Emitter(); + public readonly onModeChange: Event = this._onModeChange.event; + + /** 会话切换loading状态变化事件 */ + private readonly _onSessionLoadingChange = new Emitter(); + public readonly onSessionLoadingChange: Event = this._onSessionLoadingChange.event; + private _latestRequestId: string; public get latestRequestId(): string { return this._latestRequestId; @@ -47,16 +69,31 @@ export class ChatInternalService extends Disposable { } init() { - this.chatManagerService.onStorageInit(() => { + this.chatManagerService.onStorageInit(async () => { const sessions = this.chatManagerService.getSessions(); if (sessions.length > 0) { - this.activateSession(sessions[sessions.length - 1].sessionId); + await this.activateSession(sessions[sessions.length - 1].sessionId); } else { this.createSessionModel(); } }); } + /** + * 设置当前会话的模式 + * @param modeId 模式 ID + */ + async setSessionMode(modeId: string): Promise { + const sessionId = this.#sessionModel?.sessionId; + if (!sessionId) { + throw new Error('No active session'); + } + + await this.aiBackService.setSessionMode?.(sessionId, modeId); + // 切换成功后通知前端 UI 同步更新当前模式 + this._onModeChange.fire(modeId); + } + public setLatestRequestId(id: string): void { this._latestRequestId = id; this._onChangeRequestId.fire(id); @@ -79,36 +116,54 @@ export class ChatInternalService extends Disposable { this._onCancelRequest.fire(); } - createSessionModel() { - this.#sessionModel = this.chatManagerService.startSession(); + async createSessionModel() { + // this.__isSessionLoading = true; + this._onSessionLoadingChange.fire(true); + this.#sessionModel = await this.chatManagerService.startSession(); this._onChangeSession.fire(this.#sessionModel.sessionId); + // this.__isSessionLoading = false; + this._onSessionLoadingChange.fire(false); } - clearSessionModel(sessionId?: string) { + async clearSessionModel(sessionId?: string) { sessionId = sessionId || this.#sessionModel.sessionId; this._onWillClearSession.fire(sessionId); this.chatManagerService.clearSession(sessionId); if (sessionId === this.#sessionModel.sessionId) { - this.#sessionModel = this.chatManagerService.startSession(); + this.#sessionModel = await this.chatManagerService.startSession(); } this._onChangeSession.fire(this.#sessionModel.sessionId); } getSessions() { - return this.chatManagerService.getSessions(); + const sessions = this.chatManagerService.getSessions(); + + return sessions; } getSession(sessionId: string) { return this.chatManagerService.getSession(sessionId); } - activateSession(sessionId: string) { - const targetSession = this.chatManagerService.getSession(sessionId); - if (!targetSession) { - throw new Error(`There is no session with session id ${sessionId}`); + async activateSession(sessionId: string) { + // 设置会话loading状态 + // this.__isSessionLoading = true; + this._onSessionLoadingChange.fire(true); + try { + const targetSession = this.chatManagerService.getSession(sessionId); + await this.chatManagerService.loadSession(sessionId); + // 重新获取 targetSession,因为 loadSession 可能更新了 session 对象 + const updatedSession = this.chatManagerService.getSession(sessionId); + if (!updatedSession) { + throw new Error(`There is no session with session id ${sessionId}`); + } + this.#sessionModel = updatedSession; + this._onChangeSession.fire(this.#sessionModel.sessionId); + } finally { + // 会话加载完成,关闭loading状态 + // this.__isSessionLoading = false; + this._onSessionLoadingChange.fire(false); } - this.#sessionModel = targetSession; - this._onChangeSession.fire(this.#sessionModel.sessionId); } override dispose(): void { diff --git a/packages/ai-native/src/browser/chat/chat.render.registry.ts b/packages/ai-native/src/browser/chat/chat.render.registry.ts index 4dd8a07fc3..03c9f8d725 100644 --- a/packages/ai-native/src/browser/chat/chat.render.registry.ts +++ b/packages/ai-native/src/browser/chat/chat.render.registry.ts @@ -1,3 +1,18 @@ +/** + * ChatRenderRegistry - 聊天渲染注册器 + * + * 负责管理聊天视图各部分的渲染组件注册: + * - 欢迎页面渲染 + * - AI 角色消息渲染 + * - 用户角色消息渲染 + * - 思考状态渲染 + * - 输入框渲染 + * - 思考结果渲染 + * - 视图头部渲染 + * + * 被以下类调用: + * - ChatView (chat.view.tsx): 获取注册的渲染组件 + */ import { Injectable } from '@opensumi/di'; import { Disposable } from '@opensumi/ide-core-common'; diff --git a/packages/ai-native/src/browser/chat/chat.view.tsx b/packages/ai-native/src/browser/chat/chat.view.tsx index 0be7e4fa41..da0adadc79 100644 --- a/packages/ai-native/src/browser/chat/chat.view.tsx +++ b/packages/ai-native/src/browser/chat/chat.view.tsx @@ -1,4 +1,3 @@ -import debounce from 'lodash/debounce'; import * as React from 'react'; import { MessageList } from 'react-chat-elements'; @@ -12,6 +11,7 @@ import { } from '@opensumi/ide-core-browser'; import { Popover, PopoverPosition } from '@opensumi/ide-core-browser/lib/components'; import { EnhanceIcon } from '@opensumi/ide-core-browser/lib/components/ai-native'; +import { Progress } from '@opensumi/ide-core-browser/lib/progress/progress-bar'; import { AIServiceType, ActionSourceEnum, @@ -49,6 +49,7 @@ import { } from '../../common/llm-context'; import { CodeBlockData } from '../../common/types'; import { cleanAttachedTextWrapper } from '../../common/utils'; +import AcpPermissionDialogContainer from '../acp/permission-dialog-container'; import { FileChange, FileListDisplay } from '../components/ChangeList'; import { CodeBlockWrapperInput } from '../components/ChatEditor'; import ChatHistory, { IChatHistoryItem } from '../components/ChatHistory'; @@ -125,7 +126,7 @@ export const AIChatView = () => { const llmContextService = useInjectable(LLMContextServiceToken); const layoutService = useInjectable(IMainLayoutService); - const msgHistoryManager = aiChatService.sessionModel.history; + const msgHistoryManager = aiChatService.sessionModel?.history; const containerRef = React.useRef(null); const autoScroll = React.useRef(true); const chatInputRef = React.useRef<{ setInputValue: (v: string) => void } | null>(null); @@ -136,7 +137,7 @@ export const AIChatView = () => { const workspaceService = useInjectable(IWorkspaceService); const commandService = useInjectable(CommandService); const [shortcutCommands, setShortcutCommands] = React.useState([]); - const [sessionModelId, setSessionModelId] = React.useState(aiChatService.sessionModel.modelId); + const [sessionModelId, setSessionModelId] = React.useState(aiChatService.sessionModel?.modelId); const [changeList, setChangeList] = React.useState( getFileChanges(applyService.getSessionCodeBlocks() || []), @@ -156,13 +157,23 @@ export const AIChatView = () => { }, []); const [loading, setLoading] = React.useState(false); + const [sessionLoading, setSessionLoading] = React.useState(false); const [agentId, setAgentId] = React.useState(''); const [defaultAgentId, setDefaultAgentId] = React.useState(''); const [command, setCommand] = React.useState(''); const [theme, setTheme] = React.useState(null); + // 监听会话切换loading状态 + React.useEffect(() => { + const disposer = aiChatService.onSessionLoadingChange((v) => { + setSessionLoading(v); + }); + // 默认创建一个新会话 + aiChatService.createSessionModel(); + return () => disposer.dispose(); + }, []); // 切换session或Agent输出状态变化时 React.useEffect(() => { - setSessionModelId(aiChatService.sessionModel.modelId); + setSessionModelId(aiChatService.sessionModel?.modelId); }, [loading, aiChatService.sessionModel]); React.useEffect(() => { @@ -212,6 +223,9 @@ export const AIChatView = () => { if (chatRenderRegistry.chatInputRender) { return chatRenderRegistry.chatInputRender; } + if (aiNativeConfigService.capabilities.supportsChatAssistant) { + return ChatMentionInput; + } if (aiNativeConfigService.capabilities.supportsMCP) { return ChatMentionInput; } @@ -311,7 +325,7 @@ export const AIChatView = () => { if (data.kind === 'content') { const relationId = aiReporter.start(AIServiceType.CustomReply, { message: data.content, - sessionId: aiChatService.sessionModel.sessionId, + sessionId: aiChatService.sessionModel?.sessionId, }); msgHistoryManager.addAssistantMessage({ content: data.content, @@ -321,7 +335,7 @@ export const AIChatView = () => { } else { const relationId = aiReporter.start(AIServiceType.CustomReply, { message: 'component#' + data.component, - sessionId: aiChatService.sessionModel.sessionId, + sessionId: aiChatService.sessionModel?.sessionId, }); msgHistoryManager.addAssistantMessage({ componentId: data.component, @@ -343,7 +357,7 @@ export const AIChatView = () => { const relationId = aiReporter.start(AIServiceType.Chat, { message: '', - sessionId: aiChatService.sessionModel.sessionId, + sessionId: aiChatService.sessionModel?.sessionId, }); if (role === 'assistant') { @@ -511,7 +525,13 @@ export const AIChatView = () => { handleDispatchMessage({ type: 'add', payload: [userMessage] }); }, - [chatRenderRegistry, chatRenderRegistry.chatUserRoleRender, msgHistoryManager, scrollToBottom], + [ + chatRenderRegistry, + chatRenderRegistry.chatUserRoleRender, + msgHistoryManager, + scrollToBottom, + handleDispatchMessage, + ], ); const renderReply = React.useCallback( @@ -526,9 +546,9 @@ export const AIChatView = () => { }) => { const { message, agentId, request, relationId, command, startTime, msgId } = renderModel; - const visibleAgentId = agentId === ChatProxyService.AGENT_ID ? '' : agentId; + const visibleAgentId = agentId === ChatProxyService?.AGENT_ID ? '' : agentId; - if (agentId === ChatProxyService.AGENT_ID && command) { + if (agentId === ChatProxyService?.AGENT_ID && command) { const commandHandler = chatFeatureRegistry.getSlashCommandHandler(command); if (commandHandler && commandHandler.providerRender) { setLoading(false); @@ -573,7 +593,7 @@ export const AIChatView = () => { }); handleDispatchMessage({ type: 'add', payload: [aiMessage] }); }, - [chatRenderRegistry, msgHistoryManager, scrollToBottom], + [chatRenderRegistry, msgHistoryManager, scrollToBottom, handleDispatchMessage, handleSlashCustomRender], ); const renderSimpleMarkdownReply = React.useCallback( @@ -595,7 +615,7 @@ export const AIChatView = () => { handleDispatchMessage({ type: 'add', payload: [aiMessage] }); }, - [chatRenderRegistry, msgHistoryManager, scrollToBottom], + [chatRenderRegistry, msgHistoryManager, scrollToBottom, handleDispatchMessage, agentId, command], ); const renderCustomComponent = React.useCallback( @@ -612,7 +632,7 @@ export const AIChatView = () => { ); handleDispatchMessage({ type: 'add', payload: [aiMessage] }); }, - [chatRenderRegistry, msgHistoryManager, scrollToBottom], + [chatRenderRegistry, msgHistoryManager, scrollToBottom, handleDispatchMessage], ); const handleAgentReply = React.useCallback( @@ -620,7 +640,7 @@ export const AIChatView = () => { const { message, images, agentId, command, reportExtra } = value; const { actionType, actionSource } = reportExtra || {}; - const request = aiChatService.createRequest( + const request = await aiChatService.createRequest( message.replaceAll(LLM_CONTEXT_KEY_REGEX, ''), agentId!, images, @@ -643,7 +663,7 @@ export const AIChatView = () => { userMessage: message, actionType, actionSource, - sessionId: aiChatService.sessionModel.sessionId, + sessionId: aiChatService.sessionModel?.sessionId, }, // 由于涉及 tool 调用,超时时间设置长一点 600 * 1000, @@ -676,7 +696,7 @@ export const AIChatView = () => { // 创建消息时,设置当前活跃的消息信息,便于toolCall打点 mcpServerRegistry.activeMessageInfo = { messageId: msgId, - sessionId: aiChatService.sessionModel.sessionId, + sessionId: aiChatService.sessionModel?.sessionId, }; await renderReply({ @@ -786,7 +806,13 @@ export const AIChatView = () => { const recover = React.useCallback( async (cancellationToken: CancellationToken) => { - for (const msg of msgHistoryManager.getMessages()) { + // 动态获取最新的 msgHistoryManager,而不是使用闭包中的旧引用 + const currentMsgHistoryManager = aiChatService.sessionModel?.history; + if (!currentMsgHistoryManager) { + return; + } + const messages = currentMsgHistoryManager.getMessages(); + for (const msg of currentMsgHistoryManager.getMessages()) { if (cancellationToken.isCancellationRequested) { return; } @@ -799,7 +825,7 @@ export const AIChatView = () => { images: msg.images, }); } else if (msg.role === ChatMessageRole.Assistant && msg.requestId) { - const request = aiChatService.sessionModel.getRequest(msg.requestId)!; + const request = aiChatService.sessionModel?.getRequest(msg.requestId)!; // 从storage恢复时,request为undefined if (request && !request.response.isComplete) { setLoading(true); @@ -830,7 +856,7 @@ export const AIChatView = () => { } } }, - [renderReply], + [aiChatService.sessionModel, renderReply, renderUserMessage, renderSimpleMarkdownReply, renderCustomComponent], ); React.useEffect(() => { @@ -852,6 +878,7 @@ export const AIChatView = () => {
+ {sessionLoading && } { dataSource={messageListData} />
- {aiChatService.sessionModel.slicedMessageCount ? ( + {aiChatService.sessionModel?.slicedMessageCount ? (
{formatLocalize( 'aiNative.chat.ai.assistant.limit.message', - aiChatService.sessionModel.slicedMessageCount, + aiChatService.sessionModel?.slicedMessageCount, )}
@@ -903,7 +930,7 @@ export const AIChatView = () => { )} {
+ ); }; @@ -938,13 +966,14 @@ export function DefaultChatViewHeader({ const [historyList, setHistoryList] = React.useState([]); const [currentTitle, setCurrentTitle] = React.useState(''); const handleNewChat = React.useCallback(() => { - if (aiChatService.sessionModel.history.getMessages().length > 0) { - try { - aiChatService.createSessionModel(); - } catch (error) { - messageService.error(error.message); - } - } + // if (aiChatService.sessionModel?.history.getMessages().length > 0) { + // try { + // aiChatService.createSessionModel(); + // } catch (error) { + // messageService.error(error.message); + // } + // } + aiChatService.createSessionModel(); }, [aiChatService]); const handleHistoryItemSelect = React.useCallback( (item: IChatHistoryItem) => { @@ -985,7 +1014,13 @@ export function DefaultChatViewHeader({ React.useEffect(() => { const getHistoryList = async () => { - const currentMessages = aiChatService.sessionModel.history.getMessages(); + if (!aiChatService.sessionModel) { + return; + } + + const sessions = aiChatService.getSessions(); + + const currentMessages = aiChatService.sessionModel?.history.getMessages(); const latestUserMessage = [...currentMessages].find((m) => m.role === ChatMessageRole.User); const currentTitle = latestUserMessage ? cleanAttachedTextWrapper(latestUserMessage.content).slice(0, MAX_TITLE_LENGTH) @@ -1013,14 +1048,23 @@ export function DefaultChatViewHeader({ } } - setHistoryList( - aiChatService.getSessions().map((session) => { + const historyListData = sessions.map((session, index) => { + try { const history = session.history; const messages = history.getMessages(); - const title = - messages.length > 0 ? cleanAttachedTextWrapper(messages[0].content).slice(0, MAX_TITLE_LENGTH) : ''; - const updatedAt = messages.length > 0 ? messages[messages.length - 1].replyStartTime || 0 : 0; - // const loading = session.requests[session.requests.length - 1]?.response.isComplete; + + // 修复:检查 messages[0] 是否存在 + let title = ''; + if (session?.title) { + title = session.title.slice(0, MAX_TITLE_LENGTH); + } else if (messages.length > 0 && messages[0]?.content) { + title = cleanAttachedTextWrapper(messages[0].content).slice(0, MAX_TITLE_LENGTH); + } + + // 修复:检查 lastMessage 是否存在 + const lastMessage = messages.length > 0 ? messages[messages.length - 1] : null; + const updatedAt = lastMessage?.replyStartTime || 0; + return { id: session.sessionId, title, @@ -1028,8 +1072,17 @@ export function DefaultChatViewHeader({ // TODO: 后续支持 loading: false, }; - }), - ); + } catch (error) { + return { + id: session.sessionId, + title: 'Error loading session', + updatedAt: 0, + loading: false, + }; + } + }); + + setHistoryList(historyListData); }; getHistoryList(); const toDispose = new DisposableCollection(); @@ -1042,16 +1095,16 @@ export function DefaultChatViewHeader({ } sessionListenIds.add(sessionId); toDispose.push( - aiChatService.sessionModel.history.onMessageChange(() => { + aiChatService.sessionModel?.history.onMessageChange(() => { getHistoryList(); }), ); }), ); toDispose.push( - aiChatService.sessionModel.history.onMessageChange(() => { + aiChatService.sessionModel?.history.onMessageChange(() => { getHistoryList(); - }), + }) || { dispose: () => {} }, ); return () => { toDispose.dispose(); @@ -1063,7 +1116,7 @@ export function DefaultChatViewHeader({ (AINativeSettingSectionsId.SystemPrompt, DEFAULT_SYSTEM_PROMPT), + }; + } + + async invoke( + request: IChatAgentRequest, + progress: (part: IChatProgress) => void, + history: CoreMessage[], + token: CancellationToken, + ): Promise { + const chatDeferred = new Deferred(); + const { message, command } = request; + let prompt: string = message; + + if (command) { + const commandHandler = this.chatFeatureRegistry.getSlashCommandHandler(command); + if (commandHandler && commandHandler.providerPrompt) { + const editor = this.monacoCommandRegistry.getActiveCodeEditor(); + const slashCommandPrompt = await commandHandler.providerPrompt(message, editor); + prompt = slashCommandPrompt; + } + } + + const stream = await this.aiBackService.requestStream( + prompt, + { + requestId: request.requestId, + sessionId: request.sessionId, + history, + images: request.images, + ...(await this.getRequestOptions()), + }, + token, + ); + + listenReadable(stream, { + onData: (data) => { + progress(data); + }, + onEnd: () => { + chatDeferred.resolve(); + }, + onError: (error) => { + this.messageService.error(error.message); + this.aiReporter.end(request.sessionId + '_' + request.requestId, { + message: error.message, + success: false, + command, + }); + chatDeferred.reject(error); + }, + }); + + await chatDeferred.promise; + return {}; + } + + async provideSlashCommands(_token: CancellationToken): Promise { + return this.chatFeatureRegistry.getAllSlashCommand().map((s) => ({ + ...s, + name: s.name, + description: s.description || '', + })); + } + + async provideChatWelcomeMessage(_token: CancellationToken): Promise { + return undefined; + } + + public async getRequestOptions() { + const model = this.preferenceService.get(AINativeSettingSectionsId.LLMModelSelection); + const modelId = this.preferenceService.get(AINativeSettingSectionsId.ModelID); + let apiKey: string = ''; + let baseURL: string = ''; + if (model === 'deepseek') { + apiKey = this.preferenceService.get(AINativeSettingSectionsId.DeepseekApiKey, ''); + } else if (model === 'openai') { + apiKey = this.preferenceService.get(AINativeSettingSectionsId.OpenaiApiKey, ''); + } else if (model === 'anthropic') { + apiKey = this.preferenceService.get(AINativeSettingSectionsId.AnthropicApiKey, ''); + } else { + // openai-compatible 为兜底 + apiKey = this.preferenceService.get(AINativeSettingSectionsId.OpenaiApiKey, ''); + baseURL = this.preferenceService.get(AINativeSettingSectionsId.OpenaiBaseURL, ''); + } + const maxTokens = this.preferenceService.get(AINativeSettingSectionsId.MaxTokens); + const disabledTools = await this.mcpConfigService.getDisabledTools(); + return { + clientId: this.applicationService.clientId, + model, + modelId, + apiKey, + baseURL, + maxTokens, + system: this.metadata.systemPrompt, + disabledTools, + }; + } +} diff --git a/packages/ai-native/src/browser/chat/local-storage-provider.ts b/packages/ai-native/src/browser/chat/local-storage-provider.ts new file mode 100644 index 0000000000..74f1687e64 --- /dev/null +++ b/packages/ai-native/src/browser/chat/local-storage-provider.ts @@ -0,0 +1,64 @@ +import { Autowired, Injectable } from '@opensumi/di'; +import { Domain, IStorage, STORAGE_NAMESPACE, StorageProvider } from '@opensumi/ide-core-common'; + +import { ISessionModel, ISessionProvider, SessionProviderDomain } from './session-provider'; + +/** + * LocalStorage Session Provider + * 负责从浏览器 LocalStorage 加载和保存 Session + */ +@Domain(SessionProviderDomain) +@Injectable() +export class LocalStorageProvider implements ISessionProvider { + readonly id = 'local-storage'; + + @Autowired(StorageProvider) + private storageProvider: StorageProvider; + + private _chatStorage: IStorage | null = null; + + /** + * 获取 storage 实例(延迟初始化) + */ + private async getStorage(): Promise { + if (!this._chatStorage) { + this._chatStorage = await this.storageProvider(STORAGE_NAMESPACE.CHAT); + } + return this._chatStorage; + } + + /** + * 判断是否支持处理该来源 + * 支持:'local' 前缀或无前缀(兼容旧数据) + */ + canHandle(mode: string): boolean { + return mode === 'local'; + } + + /** + * 加载所有本地 Session + */ + async loadSessions(): Promise { + const storage = await this.getStorage(); + const sessionsModelData = storage.get('sessionModels', []); + // 过滤掉空消息历史的会话 + return sessionsModelData.filter((item) => item.history?.messages?.length > 0); + } + + /** + * 加载指定 Session + */ + async loadSession(sessionId: string): Promise { + const storage = await this.getStorage(); + const sessionsModelData = storage.get('sessionModels', []); + return sessionsModelData.find((item) => item.sessionId === sessionId); + } + + /** + * 保存 Session 到 localStorage + */ + async saveSessions(sessions: ISessionModel[]): Promise { + const storage = await this.getStorage(); + storage.set('sessionModels', sessions); + } +} diff --git a/packages/ai-native/src/browser/chat/session-provider-registry.ts b/packages/ai-native/src/browser/chat/session-provider-registry.ts new file mode 100644 index 0000000000..140297285c --- /dev/null +++ b/packages/ai-native/src/browser/chat/session-provider-registry.ts @@ -0,0 +1,130 @@ +import { Autowired, INJECTOR_TOKEN, Injectable, Injector } from '@opensumi/di'; +import { Disposable, IDisposable } from '@opensumi/ide-core-common'; + +import { ISessionProvider, SessionProviderDomain } from './session-provider'; + +/** + * Session Provider Registry Token(用于 DI) + */ +export const ISessionProviderRegistry = Symbol('ISessionProviderRegistry'); + +/** + * Session Provider Registry 接口 + * 管理所有注册的 Session Provider,提供 Provider 路由功能 + */ +export interface ISessionProviderRegistry { + /** + * 注册 Provider + * @param provider Session Provider 实例 + * @returns 注销句柄 + */ + registerProvider(provider: ISessionProvider): IDisposable; + + /** + * 根据 source 前缀获取 Provider + * @param source 来源标识(如 'local', 'acp') + * @returns 对应的 Provider,未找到返回 undefined + */ + getProvider(source: string): ISessionProvider | undefined; + + /** + * 根据 Session ID 获取 Provider + * 解析 Session ID 的 source 前缀,路由到对应 Provider + * @param sessionId 本地 Session ID(如 'local:uuid', 'acp:sess_123') + * @returns 对应的 Provider,未找到返回 undefined + */ + getProviderBySessionId(sessionId: string): ISessionProvider | undefined; + + /** + * 获取所有已注册的 Provider + * @returns Provider 列表 + */ + getAllProviders(): ISessionProvider[]; +} + +/** + * Session Provider Registry 实现 + * 轻量级路由,不负责加载逻辑,只负责 Provider 注册和查找 + */ +@Injectable() +export class SessionProviderRegistry extends Disposable implements ISessionProviderRegistry { + @Autowired(INJECTOR_TOKEN) + private injector: Injector; + + private providers: Map = new Map(); + private initialized = false; + + constructor() { + super(); + this.initialize(); + } + + /** + * 初始化:从 DI 收集所有标注了 @Domain(SessionProviderDomain) 的 Provider + */ + initialize(): void { + if (this.initialized) { + return; + } + + // 从 DI 获取所有 SessionProviderDomain 的实例 + const domainProviders = this.injector.getFromDomain(SessionProviderDomain) as ISessionProvider[]; + + for (const provider of domainProviders) { + this.registerProvider(provider); + } + + this.initialized = true; + } + + /** + * 注册 Provider + */ + registerProvider(provider: ISessionProvider): IDisposable { + if (this.providers.has(provider.id)) { + // Provider 已存在,将被覆盖 + } + + this.providers.set(provider.id, provider); + + return { + dispose: () => { + this.providers.delete(provider.id); + }, + }; + } + + /** + * 根据 source 前缀获取 Provider + */ + getProvider(source: string): ISessionProvider | undefined { + // 先尝试直接匹配 source + const providers = Array.from(this.providers.values()); + for (const provider of providers) { + try { + const canHandleResult = provider.canHandle(source); + if (canHandleResult) { + return provider; + } + } catch (error) { + // Provider canHandle() threw error + } + } + return undefined; + } + + /** + * 根据 Session ID 获取 Provider + */ + getProviderBySessionId(sessionId: string): ISessionProvider | undefined { + const provider = this.getProvider(sessionId); + return provider; + } + + /** + * 获取所有已注册的 Provider + */ + getAllProviders(): ISessionProvider[] { + return Array.from(this.providers.values()); + } +} diff --git a/packages/ai-native/src/browser/chat/session-provider.ts b/packages/ai-native/src/browser/chat/session-provider.ts new file mode 100644 index 0000000000..f7570d12bc --- /dev/null +++ b/packages/ai-native/src/browser/chat/session-provider.ts @@ -0,0 +1,109 @@ +import { IHistoryChatMessage } from '@opensumi/ide-core-common/lib/types/ai-native'; + +import { IChatFollowup, IChatRequestMessage, IChatResponseErrorDetails } from '../../common'; + +import { IChatProgressResponseContent } from './chat-model'; + +/** + * Session 模型数据结构(用于持久化) + */ +export interface ISessionModel { + sessionId: string; + modelId?: string; + history: { additional: Record; messages: IHistoryChatMessage[] }; + requests: { + requestId: string; + message: IChatRequestMessage; + response: { + isCanceled: boolean; + responseText: string; + responseContents: IChatProgressResponseContent[]; + responseParts: IChatProgressResponseContent[]; + errorDetails: IChatResponseErrorDetails | undefined; + followups: IChatFollowup[] | undefined; + }; + }[]; + lastLoadedAt?: number; + title?: string; +} + +/** + * Session Provider 接口 + * 抽象不同数据源的 Session 加载逻辑 + */ +export interface ISessionProvider { + /** Provider 唯一标识 */ + readonly id: string; + + /** + * 判断是否支持处理该来源的 Session + * @param source Session 来源标识(如 'local', 'acp', 'acp:sess_123') + */ + canHandle(source: string): boolean; + + /** + * 创建新会话 + * @param title 可选的会话标题 + * @returns 创建的 Session 数据 + */ + createSession?(): Promise; + + /** + * 加载所有可用会话 + * @returns Session 数据列表 + */ + loadSessions(): Promise; + + /** + * 加载指定会话 + * @param sessionId 本地 Session ID + * @returns Session 数据,不存在时返回 undefined + */ + loadSession(sessionId: string): Promise; + + /** + * 保存会话(可选实现) + * @param sessions Session 数据列表 + */ + saveSessions?(sessions: ISessionModel[]): Promise; +} + +/** + * Session Provider Token(用于 DI) + */ +export const ISessionProvider = Symbol('ISessionProvider'); + +/** + * Session Provider Domain(用于 DI 多实例注入) + */ +export const SessionProviderDomain = Symbol('SessionProviderDomain'); + +/** + * Session 加载状态枚举 + */ +export enum SessionLoadState { + /** 正在从远程加载 */ + LOADING = 'loading', + /** 完整数据已加载 */ + LOADED = 'loaded', + /** 加载失败 */ + ERROR = 'error', +} + +/** + * Session 来源类型 + */ +export type SessionSource = 'local' | 'acp'; + +/** + * 解析 Session ID,提取来源和原始 ID + * @param sessionId 本地 Session ID(如 'local:uuid', 'acp:sess_123') + * @returns 来源标识和原始 ID + */ +export function parseSessionId(sessionId: string): { source: SessionSource; originalId: string } { + if (sessionId.startsWith('acp:')) { + return { source: 'acp', originalId: sessionId.slice(4) }; + } + // 默认视为 local 来源(兼容旧数据) + return { source: 'local', originalId: sessionId }; +} diff --git a/packages/ai-native/src/browser/components/ChatHistory.tsx b/packages/ai-native/src/browser/components/ChatHistory.tsx index 64f980693f..96fdcca6d7 100644 --- a/packages/ai-native/src/browser/components/ChatHistory.tsx +++ b/packages/ai-native/src/browser/components/ChatHistory.tsx @@ -233,7 +233,7 @@ const ChatHistory: FC = memo(
{groupedHistoryList.map((group) => (
-
{group.key}
+ {/*
{group.key}
*/} {group.items.map(renderHistoryItem)}
))} diff --git a/packages/ai-native/src/browser/components/ChatMentionInput.tsx b/packages/ai-native/src/browser/components/ChatMentionInput.tsx index b487539c23..2c28920f26 100644 --- a/packages/ai-native/src/browser/components/ChatMentionInput.tsx +++ b/packages/ai-native/src/browser/components/ChatMentionInput.tsx @@ -3,6 +3,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Image } from '@opensumi/ide-components/lib/image'; import { + AINativeConfigService, LabelService, PreferenceService, RecentFilesManager, @@ -38,7 +39,7 @@ import { RulesService } from '../rules/rules.service'; import styles from './components.module.less'; import { MentionInput } from './mention-input/mention-input'; -import { FooterButtonPosition, FooterConfig, MentionItem, MentionType } from './mention-input/types'; +import { FooterButtonPosition, FooterConfig, MentionItem, MentionType, ModeOption } from './mention-input/types'; export interface IChatMentionInputProps { onSend: ( @@ -68,6 +69,7 @@ export interface IChatMentionInputProps { disableModelSelector?: boolean; sessionModelId?: string; contextService?: LLMContextService; + agentModes?: Array<{ id: string; name: string; description?: string }>; } export const ChatMentionInput = (props: IChatMentionInputProps) => { @@ -75,7 +77,9 @@ export const ChatMentionInput = (props: IChatMentionInputProps) => { const [value, setValue] = useState(props.value || ''); const [images, setImages] = useState(props.images || []); + const [currentMode, setCurrentMode] = useState(props.agentModes?.[0]?.id || 'default'); const aiChatService = useInjectable(IChatInternalService); + const aiNativeConfigService = useInjectable(AINativeConfigService); const commandService = useInjectable(CommandService); const searchService = useInjectable(FileSearchServicePath); const recentFilesManager = useInjectable(RecentFilesManager); @@ -97,6 +101,21 @@ export const ChatMentionInput = (props: IChatMentionInputProps) => { commandService.executeCommand(RulesCommands.OPEN_RULES_FILE.id); }, [commandService]); + // 监听 ACP Agent 模式切换成功事件,同步更新 UI + useEffect(() => { + const disposable = aiChatService.onModeChange((modeId) => { + setCurrentMode(modeId); + }); + return () => disposable.dispose(); + }, [aiChatService]); + + // 当 agentModes 变化时,更新 currentMode 为第一个 mode + useEffect(() => { + if (props.agentModes?.length && !props.agentModes.find((m) => m.id === currentMode)) { + setCurrentMode(props.agentModes[0].id); + } + }, [props.agentModes]); + useEffect(() => { if (props.value !== value) { setValue(props.value || ''); @@ -421,8 +440,21 @@ export const ChatMentionInput = (props: IChatMentionInputProps) => { }, }, ]; + // Mode 选项:优先使用 Agent 初始化时返回的真实 modes,降级为硬编码默认值 + const modeOptions: ModeOption[] = useMemo( + () => + props.agentModes?.length + ? props.agentModes + : [{ id: 'default', name: 'Default', description: 'Require approval for edits' }], + [props.agentModes], + ); + const defaultMentionInputFooterOptions: FooterConfig = useMemo( () => ({ + modeOptions, + defaultMode: modeOptions[0]?.id || 'default', + currentMode, + showModeSelector: modeOptions.length > 1, modelOptions: [ { value: 'qwen-plus-latest', @@ -460,41 +492,62 @@ export const ChatMentionInput = (props: IChatMentionInputProps) => { ], defaultModel: props.sessionModelId || preferenceService.get(AINativeSettingSectionsId.ModelID) || 'deepseek-r1', - buttons: [ - { - id: 'mcp-server', - icon: 'mcp', - title: 'MCP Server', - onClick: handleShowMCPConfig, - position: FooterButtonPosition.LEFT, - }, - { - id: 'rules', - icon: 'rules', - title: 'Rules', - onClick: handleShowRules, - position: FooterButtonPosition.LEFT, - }, - { - id: 'upload-image', - icon: 'image', - title: localize('aiNative.chat.imageUpload'), - onClick: () => { - const input = document.createElement('input'); - input.type = 'file'; - input.accept = 'image/*'; - input.onchange = (e) => { - const files = (e.target as HTMLInputElement).files; - if (files?.length) { - handleImageUpload(Array.from(files)); - } - }; - input.click(); - }, - position: FooterButtonPosition.LEFT, - }, - ], - showModelSelector: true, + buttons: aiNativeConfigService.capabilities.supportsAgentMode + ? [ + { + id: 'upload-image', + icon: 'image', + title: localize('aiNative.chat.imageUpload'), + onClick: () => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'image/*'; + input.onchange = (e) => { + const files = (e.target as HTMLInputElement).files; + if (files?.length) { + handleImageUpload(Array.from(files)); + } + }; + input.click(); + }, + position: FooterButtonPosition.LEFT, + }, + ] + : [ + { + id: 'mcp-server', + icon: 'mcp', + title: 'MCP Server', + onClick: handleShowMCPConfig, + position: FooterButtonPosition.LEFT, + }, + { + id: 'rules', + icon: 'rules', + title: 'Rules', + onClick: handleShowRules, + position: FooterButtonPosition.LEFT, + }, + { + id: 'upload-image', + icon: 'image', + title: localize('aiNative.chat.imageUpload'), + onClick: () => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'image/*'; + input.onchange = (e) => { + const files = (e.target as HTMLInputElement).files; + if (files?.length) { + handleImageUpload(Array.from(files)); + } + }; + input.click(); + }, + position: FooterButtonPosition.LEFT, + }, + ], + showModelSelector: aiNativeConfigService.capabilities.supportsAgentMode ? false : true, // agnet 模式不支持选择模型 disableModelSelector: props.disableModelSelector, }), [iconService, handleShowMCPConfig, handleShowRules, props.disableModelSelector, props.sessionModelId], @@ -547,6 +600,18 @@ export const ChatMentionInput = (props: IChatMentionInputProps) => { [images], ); + const handleModeChange = useCallback( + async (modeId: string) => { + try { + await aiChatService.setSessionMode(modeId); + } catch (error) { + // console.error('Failed to switch mode:', error); + messageService.error('Failed to switch mode: ' + (error instanceof Error ? error.message : String(error))); + } + }, + [aiChatService, messageService], + ); + const handleDeleteImage = useCallback( (index: number) => { setImages(images.filter((_, i) => i !== index)); @@ -568,6 +633,7 @@ export const ChatMentionInput = (props: IChatMentionInputProps) => { footerConfig={defaultMentionInputFooterOptions} onImageUpload={handleImageUpload} contextService={contextService} + onModeChange={handleModeChange} />
); diff --git a/packages/ai-native/src/browser/components/mention-input/mention-input.tsx b/packages/ai-native/src/browser/components/mention-input/mention-input.tsx index 3aa8a3e9fc..72105b2ccf 100644 --- a/packages/ai-native/src/browser/components/mention-input/mention-input.tsx +++ b/packages/ai-native/src/browser/components/mention-input/mention-input.tsx @@ -1,13 +1,15 @@ import cls from 'classnames'; import * as React from 'react'; -import { getSymbolIcon, localize } from '@opensumi/ide-core-browser'; -import { Icon, Popover, PopoverPosition, Select, getIcon } from '@opensumi/ide-core-browser/lib/components'; +import { getSymbolIcon, localize, useInjectable } from '@opensumi/ide-core-browser'; +import { Icon, Popover, PopoverPosition, getIcon } from '@opensumi/ide-core-browser/lib/components'; import { EnhanceIcon } from '@opensumi/ide-core-browser/lib/components/ai-native'; import { URI } from '@opensumi/ide-utils'; import { FileContext } from '../../../common/llm-context'; import { ProjectRule } from '../../../common/types'; +import { PermissionDialogManager } from '../../acp/permission-dialog-container'; +import { PermissionDialogWidget } from '../permission-dialog-widget'; import styles from './mention-input.module.less'; import { MentionPanel } from './mention-panel'; @@ -39,6 +41,7 @@ export const MentionInput: React.FC = ({ showModelSelector: false, }, contextService, + onModeChange, }) => { const editorRef = React.useRef(null); const [mentionState, setMentionState] = React.useState({ @@ -58,6 +61,9 @@ export const MentionInput: React.FC = ({ // 添加模型选择状态 const [selectedModel, setSelectedModel] = React.useState(footerConfig.defaultModel || ''); + // 添加 Mode 选择状态 + const [selectedMode, setSelectedMode] = React.useState(footerConfig.defaultMode || ''); + // 添加缓存状态,用于存储二级菜单项 const [secondLevelCache, setSecondLevelCache] = React.useState>({}); @@ -85,6 +91,10 @@ export const MentionInput: React.FC = ({ }> >([]); + // 权限弹窗服务 + const permissionDialogManager = useInjectable(PermissionDialogManager); + const [optionsBottomPosition, setOptionsBottomPosition] = React.useState(0); + const getCurrentItems = (): MentionItem[] => { if (mentionState.level === 0) { return mentionItems; @@ -122,6 +132,20 @@ export const MentionInput: React.FC = ({ setSelectedModel(footerConfig.defaultModel || ''); }, [footerConfig.defaultModel]); + // 外部受控模式:当 footerConfig.currentMode 变化时(如 ACP Mention 切换通知),同步更新选择器 + React.useEffect(() => { + if (footerConfig.currentMode) { + setSelectedMode(footerConfig.currentMode); + } + }, [footerConfig.currentMode]); + + // 当 defaultMode 从空值变为有值时(如 mentionModes 异步加载完成),更新 selectedMode + React.useEffect(() => { + if (footerConfig.defaultMode && !selectedMode) { + setSelectedMode(footerConfig.defaultMode); + } + }, [footerConfig.defaultMode]); + React.useEffect(() => { if (mentionState.level === 1 && mentionState.parentType && debouncedSecondLevelFilter !== undefined) { // 查找父级菜单项 @@ -989,6 +1013,15 @@ export const MentionInput: React.FC = ({ [selectedModel, onSelectionChange], ); + // 处理 Mode 选择变更 + const handleModeChange = React.useCallback( + (value: string) => { + setSelectedMode(value); + onModeChange?.(value); + }, + [onModeChange], + ); + // 修改 handleSend 函数 const handleSend = () => { if (!editorRef.current) { @@ -1257,6 +1290,7 @@ export const MentionInput: React.FC = ({ return (
+ {renderContextPreview()} {mentionState.active && (
@@ -1299,6 +1333,25 @@ export const MentionInput: React.FC = ({ onThinkingChange={footerConfig.onThinkingChange} />, )} + + {footerConfig.showModeSelector && + footerConfig.modeOptions && + footerConfig.modeOptions.length > 0 && + renderModelSelectorTip( + ({ + label: opt.name, + value: opt.id, + description: opt.description, + }))} + value={selectedMode} + onChange={handleModeChange} + className={styles.mode_selector} + size='small' + disabled={footerConfig.disableModeSelector} + />, + )} + {renderButtons(FooterButtonPosition.LEFT)}
diff --git a/packages/ai-native/src/browser/components/mention-input/types.ts b/packages/ai-native/src/browser/components/mention-input/types.ts index c24f0de6a7..5a82ce1241 100644 --- a/packages/ai-native/src/browser/components/mention-input/types.ts +++ b/packages/ai-native/src/browser/components/mention-input/types.ts @@ -92,7 +92,11 @@ interface FooterButton { onClick?: () => void; position: FooterButtonPosition; } - +export interface ModeOption { + id: string; + name: string; + description?: string; +} export interface FooterConfig { modelOptions?: ModelOption[]; extendedModelOptions?: ExtendedModelOption[]; @@ -103,6 +107,13 @@ export interface FooterConfig { showThinking?: boolean; thinkingEnabled?: boolean; onThinkingChange?: (enabled: boolean) => void; + // Mode 选择器配置 + modeOptions?: ModeOption[]; + defaultMode?: string; + /** 受控的当前模式 ID,变化时会同步更新选择器显示 */ + currentMode?: string; + showModeSelector?: boolean; + disableModeSelector?: boolean; } export interface MentionInputProps { @@ -118,6 +129,10 @@ export interface MentionInputProps { labelService?: LabelService; workspaceService?: IWorkspaceService; contextService?: LLMContextService; + // Agent 选择回调 + onAgentChange?: (agentId: string) => void; + // Mode 选择回调 + onModeChange?: (modeId: string) => void; } export const MENTION_KEYWORD = '@'; diff --git a/packages/ai-native/src/browser/components/permission-dialog-widget.module.less b/packages/ai-native/src/browser/components/permission-dialog-widget.module.less new file mode 100644 index 0000000000..5dda0775a4 --- /dev/null +++ b/packages/ai-native/src/browser/components/permission-dialog-widget.module.less @@ -0,0 +1,131 @@ +.permission_dialog_container { + position: absolute; + left: 0px; + right: 0px; + z-index: 1000; + outline: none; + background-color: var(--editor-background); +} + +.permission_dialog { + display: flex; + flex-direction: column; + border-radius: 6px; + border: 1px solid var(--kt-editorWidget-border); + box-shadow: var(--kt-widget-shadow, 0 4px 12px rgba(0, 0, 0, 0.15)); + padding: 8px; + background-color: var(--kt-editorWidget-background); +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0; + + &.has_content { + margin-bottom: 6px; + } +} + +.title { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.9em; + font-weight: 600; + color: var(--foreground); +} + +.warning_icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + border-radius: 50%; + background-color: var(--kt-warningBackground, #f0ad4e); + color: var(--kt-warningForeground, #fff); + font-size: 10px; +} + +.close_button { + background: none; + border: none; + cursor: pointer; + padding: 4px; + color: var(--descriptionForeground); + + &:hover { + color: var(--foreground); + } +} + +.content { + font-size: 0.8em; + color: var(--descriptionForeground); + margin-bottom: 8px; + font-family: var(--monaco-monospace-font, monospace); + word-break: break-word; + white-space: pre-wrap; + max-height: 80px; + overflow-y: auto; + padding: 6px 8px; + background-color: var(--input-background); + border-radius: 4px; +} + +.options { + display: flex; + flex-direction: column; + gap: 2px; +} + +.option_button { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + border: 0; + border-radius: 4px; + font-size: 0.85em; + background-color: transparent; + color: var(--foreground); + cursor: pointer; + text-align: left; + outline: none; + transition: all 0.2s ease; + + &:global(.focused) { + color: var(--kt-tree-inactiveSelectionForeground); + background: var(--kt-tree-inactiveSelectionBackground); + } + + &:hover { + color: var(--kt-tree-inactiveSelectionForeground); + background: var(--kt-tree-inactiveSelectionBackground); + } +} + +.option_key { + min-width: 18px; + height: 18px; + border-radius: 4px; + font-size: 0.8em; + font-weight: 600; + background-color: var(--kt-input-border); + color: var(--descriptionForeground); + display: flex; + align-items: center; + justify-content: center; +} + +.option_button:hover .option_key, +.option_button:global(.focused) .option_key { + background-color: var(--kt-primaryButton-background); + color: var(--kt-primaryButton-foreground); +} + +.option_text { + flex: 1; +} diff --git a/packages/ai-native/src/browser/components/permission-dialog-widget.tsx b/packages/ai-native/src/browser/components/permission-dialog-widget.tsx new file mode 100644 index 0000000000..c0efbf7e2d --- /dev/null +++ b/packages/ai-native/src/browser/components/permission-dialog-widget.tsx @@ -0,0 +1,143 @@ +import cls from 'classnames'; +import * as React from 'react'; + +import { useInjectable } from '@opensumi/ide-core-browser'; +import { getIcon } from '@opensumi/ide-core-browser/lib/components'; + +import { ShowPermissionDialogParams } from '../acp'; +import { AcpPermissionBridgeService } from '../acp/permission-bridge.service'; +import { PermissionDialogManager } from '../acp/permission-dialog-container'; + +import styles from './permission-dialog-widget.module.less'; + +export interface PermissionDialogWidgetProps { + dialogManager: PermissionDialogManager; + bottom: number; +} + +export const PermissionDialogWidget: React.FC = ({ dialogManager, bottom }) => { + const [dialogs, setDialogs] = React.useState>([]); + const [focusedIndex, setFocusedIndex] = React.useState(0); + const containerRef = React.useRef(null); + + const permissionBridgeService = useInjectable(AcpPermissionBridgeService); + + React.useEffect(() => { + const unsubscribe = dialogManager.subscribe((newDialogs) => { + setDialogs(newDialogs); + setFocusedIndex(0); + }); + const initialDialogs = dialogManager.getDialogs(); + setDialogs(initialDialogs); + return unsubscribe; + }, [dialogManager]); + + React.useEffect(() => { + if (dialogs.length > 0) { + window.addEventListener('keydown', handleKeyboard); + containerRef.current?.focus(); + } + return () => window.removeEventListener('keydown', handleKeyboard); + }, [dialogs.length, dialogs]); + + const handleKeyboard = (e: KeyboardEvent) => { + if (dialogs.length === 0) { + return; + } + const options = dialogs[0].params.options || []; + + if (e.key === 'ArrowDown') { + e.preventDefault(); + + setFocusedIndex((prev) => Math.min(prev + 1, options.length - 1)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + + setFocusedIndex((prev) => Math.max(prev - 1, 0)); + } else if (e.key === 'Enter') { + e.preventDefault(); + const option = options[focusedIndex]; + if (option) { + // 通知 Bridge Service 用户决策 + permissionBridgeService.handleUserDecision(dialogs[0].requestId, option.optionId, option.kind); + dialogManager.removeDialog(dialogs[0].requestId); + } + } else if (e.key === 'Escape') { + e.preventDefault(); + dialogManager.removeDialog(dialogs[0].requestId); + // Escape 视为超时/取消 + permissionBridgeService.handleDialogClose(dialogs[0].requestId); + } + }; + + if (dialogs.length === 0) { + return null; + } + + const current = dialogs[0]; + const params = current.params; + + // 智能标题 + let smartTitle = params.title || 'Permission Required'; + if (params.kind === 'edit' || params.kind === 'write') { + smartTitle = `Make this edit to ${params.locations?.[0]?.path?.split('/').pop() || 'file'}?`; + } else if (params.kind === 'execute' || params.kind === 'bash') { + smartTitle = 'Allow this bash command?'; + } else if (params.kind === 'read') { + smartTitle = `Allow read from ${params.locations?.[0]?.path?.split('/').pop() || 'file'}?`; + } + + const shouldShowContent = params.content; + + return ( +
+
+ {/* 标题栏 */} +
+
+ ! + {smartTitle} +
+ +
+ + {/* 内容 */} + {shouldShowContent && params.content &&
{params.content}
} + + {/* 选项 */} +
+ {(params.options || []).map((option, index) => { + const isFocused = focusedIndex === index; + return ( + + ); + })} +
+
+
+ ); +}; diff --git a/packages/ai-native/src/browser/index.ts b/packages/ai-native/src/browser/index.ts index c1189c6d45..1240e9fdab 100644 --- a/packages/ai-native/src/browser/index.ts +++ b/packages/ai-native/src/browser/index.ts @@ -14,6 +14,8 @@ import { ResolveConflictRegistryToken, } from '@opensumi/ide-core-browser'; import { + AcpPermissionServicePath, + AcpPermissionServiceToken, IntelligentCompletionsRegistryToken, MCPConfigServiceToken, ProblemFixRegistryToken, @@ -24,6 +26,7 @@ import { FolderFilePreferenceProvider } from '@opensumi/ide-preferences/lib/brow import { ChatProxyServiceToken, + DefaultChatAgentToken, IAIInlineCompletionsProvider, IChatAgentService, IChatInternalService, @@ -35,8 +38,13 @@ import { import { LLMContextServiceToken } from '../common/llm-context'; import { MCPServerManager, MCPServerManagerPath } from '../common/mcp-server-manager'; import { ChatAgentPromptProvider, DefaultChatAgentPromptProvider } from '../common/prompts/context-prompt-provider'; +import { ACPChatAgentPromptProvider } from '../common/prompts/empty-prompt-provider'; +import { AcpPermissionBridgeService, AcpPermissionRpcService } from './acp'; +import { AcpPermissionDialogContribution, PermissionDialogManager } from './acp/permission-dialog-container'; import { AINativeBrowserContribution } from './ai-core.contribution'; +import { AcpChatAgent } from './chat/acp-chat-agent'; +import { ACPSessionProvider } from './chat/acp-session-provider'; import { ApplyService } from './chat/apply.service'; import { ChatAgentService } from './chat/chat-agent.service'; import { ChatAgentViewService } from './chat/chat-agent.view.service'; @@ -46,6 +54,8 @@ import { ChatService } from './chat/chat.api.service'; import { ChatFeatureRegistry } from './chat/chat.feature.registry'; import { ChatInternalService } from './chat/chat.internal.service'; import { ChatRenderRegistry } from './chat/chat.render.registry'; +import { LocalStorageProvider } from './chat/local-storage-provider'; +import { ISessionProviderRegistry, SessionProviderRegistry } from './chat/session-provider-registry'; import { LlmContextContribution } from './context/llm-context.contribution'; import { LLMContextServiceImpl } from './context/llm-context.service'; import { AICodeActionContribution } from './contrib/code-action/code-action.contribution'; @@ -106,6 +116,17 @@ export class AINativeModule extends BrowserModule { MCPConfigContribution, MCPConfigCommandContribution, MCPPreferencesContribution, + AcpPermissionDialogContribution, + PermissionDialogManager, + AcpPermissionBridgeService, + + { + token: ISessionProviderRegistry, + useClass: SessionProviderRegistry, + }, + // Session Providers + LocalStorageProvider, + ACPSessionProvider, // MCP Server Contributions START ListDirTool, @@ -178,6 +199,10 @@ export class AINativeModule extends BrowserModule { token: ChatProxyServiceToken, useClass: ChatProxyService, }, + { + token: DefaultChatAgentToken, + useClass: AcpChatAgent, + }, { token: ChatServiceToken, useClass: ChatService, @@ -204,7 +229,13 @@ export class AINativeModule extends BrowserModule { }, { token: ChatAgentPromptProvider, - useClass: DefaultChatAgentPromptProvider, + useFactory(injector) { + const config = injector.get(AINativeConfigService); + if (config.capabilities.supportsAgentMode) { + return new ACPChatAgentPromptProvider(); + } + return new DefaultChatAgentPromptProvider(); + }, }, { token: InlineDiffServiceToken, @@ -228,6 +259,10 @@ export class AINativeModule extends BrowserModule { dropdownForTag: true, tag: 'mcp', }, + { + token: AcpPermissionServiceToken, + useClass: AcpPermissionRpcService, + }, ]; backServices = [ @@ -244,5 +279,9 @@ export class AINativeModule extends BrowserModule { clientToken: TokenMCPServerProxyService, servicePath: SumiMCPServerProxyServicePath, }, + { + servicePath: AcpPermissionServicePath, + clientToken: AcpPermissionServiceToken, + }, ]; } diff --git a/packages/ai-native/src/browser/layout/ai-layout.tsx b/packages/ai-native/src/browser/layout/ai-layout.tsx index 36b0d78e65..1929c6353c 100644 --- a/packages/ai-native/src/browser/layout/ai-layout.tsx +++ b/packages/ai-native/src/browser/layout/ai-layout.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { SlotLocation, SlotRenderer, useInjectable } from '@opensumi/ide-core-browser'; import { BoxPanel, SplitPanel, getStorageValue } from '@opensumi/ide-core-browser/lib/components'; @@ -6,10 +6,36 @@ import { DesignLayoutConfig } from '@opensumi/ide-core-browser/lib/layout/consta import { AI_CHAT_VIEW_ID } from '../../common'; +// 使用 UA 判断是否为移动设备 +const isMobileDevice = () => { + if (typeof navigator === 'undefined') { + return false; + } + return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); +}; + export const AILayout = () => { const { layout } = getStorageValue(); const designLayoutConfig = useInjectable(DesignLayoutConfig); + // 判断是否应该显示完整布局 + const shouldShowFullLayout = !isMobileDevice(); + + // 移动端模式:只渲染 AI_CHAT_VIEW_ID,添加 mobile class + if (!shouldShowFullLayout) { + return ( + + ); + } + + // 正常模式:渲染完整布局 const defaultRightSize = useMemo( () => (designLayoutConfig.useMergeRightWithLeftPanel ? 0 : 49), [designLayoutConfig.useMergeRightWithLeftPanel], @@ -64,7 +90,7 @@ export const AILayout = () => { slot={AI_CHAT_VIEW_ID} isTabbar={true} defaultSize={layout['AI-Chat']?.currentId ? layout['AI-Chat']?.size || 360 : 0} - maxResize={420} + maxResize={1080} minResize={280} minSize={0} /> diff --git a/packages/ai-native/src/browser/mcp/base-apply.service.ts b/packages/ai-native/src/browser/mcp/base-apply.service.ts index e8370cc198..58aadf1a05 100644 --- a/packages/ai-native/src/browser/mcp/base-apply.service.ts +++ b/packages/ai-native/src/browser/mcp/base-apply.service.ts @@ -189,7 +189,7 @@ export abstract class BaseApplyService extends WithEventBus { if (!sessionModel) { return []; } - const sessionAdditionals = sessionModel.history.sessionAdditionals; + const sessionAdditionals = sessionModel?.history?.sessionAdditionals; return Array.from(sessionAdditionals.values()) .map((additional) => (additional.codeBlockMap || {}) as { [toolCallId: string]: CodeBlockData }) .reduce((acc, cur) => { diff --git a/packages/ai-native/src/common/index.ts b/packages/ai-native/src/common/index.ts index 02bc735015..70ce008d12 100644 --- a/packages/ai-native/src/common/index.ts +++ b/packages/ai-native/src/common/index.ts @@ -129,6 +129,7 @@ export interface ChatCompletionRequestMessage { export const IChatInternalService = Symbol('IChatInternalService'); export const IChatManagerService = Symbol('IChatManagerService'); export const IChatAgentService = Symbol('IChatAgentService'); +export const DefaultChatAgentToken = Symbol('DefaultChatAgentToken'); export const ChatProxyServiceToken = Symbol('ChatProxyServiceToken'); diff --git a/packages/ai-native/src/common/prompts/empty-prompt-provider.ts b/packages/ai-native/src/common/prompts/empty-prompt-provider.ts new file mode 100644 index 0000000000..a74cf7dc2b --- /dev/null +++ b/packages/ai-native/src/common/prompts/empty-prompt-provider.ts @@ -0,0 +1,15 @@ +import { Injectable } from '@opensumi/di'; + +import { SerializedContext } from '../llm-context'; + +import { ChatAgentPromptProvider } from './context-prompt-provider'; + +/** + * 用于acp agent 不做任何处理 + */ +@Injectable() +export class ACPChatAgentPromptProvider implements ChatAgentPromptProvider { + async provideContextPrompt(context: SerializedContext, userMessage: string) { + return userMessage; + } +} diff --git a/packages/ai-native/src/node/acp/acp-agent.service.ts b/packages/ai-native/src/node/acp/acp-agent.service.ts new file mode 100644 index 0000000000..34df1bfa44 --- /dev/null +++ b/packages/ai-native/src/node/acp/acp-agent.service.ts @@ -0,0 +1,725 @@ +import { Autowired, Injectable } from '@opensumi/di'; +import { + AcpCliClientServiceToken, + type CancelNotification, + type ContentBlock, + IAcpCliClientService, + type ListSessionsRequest, + type ListSessionsResponse, + type LoadSessionRequest, + type NewSessionRequest, + type RequestPermissionRequest, + type SessionMode, + type SessionModeState, + type SessionNotification, + type SetSessionModeRequest, + type ToolCallUpdate, +} from '@opensumi/ide-core-common/lib/types/ai-native/acp-types'; +import { + AgentProcessConfig, + DEFAULT_AGENT_TYPE, + getAgentConfig, +} from '@opensumi/ide-core-common/lib/types/ai-native/agent-types'; +import { AppConfig, INodeLogger } from '@opensumi/ide-core-node'; +import { SumiReadableStream } from '@opensumi/ide-utils/lib/stream'; + +import { CliAgentProcessManagerToken, ICliAgentProcessManager } from './cli-agent-process-manager'; +import { AcpAgentRequestHandler } from './handlers/agent-request.handler'; +import { AcpTerminalHandler } from './handlers/terminal.handler'; + +export interface SessionLoadResult { + sessionId: string; + processId: string; + modes: SessionMode[]; + status: AgentSessionStatus; + /** + * 从 Agent 接收到的所有 session/update 消息 + */ + historyUpdates: SessionNotification[]; +} + +// ============================================================================ +// DI Token +// ============================================================================ + +export const AcpAgentServiceToken = Symbol('AcpAgentServiceToken'); + +// ============================================================================ +// Agent Session Types +// ============================================================================ + +export type AgentSessionStatus = 'initializing' | 'ready' | 'running' | 'stopping' | 'stopped' | 'error'; + +export interface SimpleMessage { + role: 'user' | 'assistant' | 'system' | 'tool'; + content: string; +} + +export interface AgentSessionInfo { + sessionId: string; + processId: string; + modes: SessionMode[]; + status: AgentSessionStatus; +} + +export type AgentUpdateType = 'thought' | 'message' | 'tool_call' | 'tool_result' | 'done'; + +export interface AgentUpdate { + type: AgentUpdateType; + content: string; + toolCall?: SimpleToolCall; +} + +export interface SimpleToolCall { + name: string; + input: Record; +} + +export interface PermissionResult { + approved: boolean; + input?: Record; +} + +/** + * Agent 请求参数 + */ +export interface AgentRequest { + prompt: string; + /** ACP session/prompt 使用的 sessionId(来自 ACP Agent 的 session ID) */ + sessionId: string; + images?: string[]; + history?: SimpleMessage[]; +} + +/** + * 无状态的 ACP Agent 服务接口 + */ +export interface IAcpAgentService { + /** + * 初始化 Agent 进程 + * @param config - Agent 配置 + */ + initializeAgent(config: AgentProcessConfig): Promise; + + /** + * 加载已有 Agent Session + */ + loadSession(sessionId: string, config: AgentProcessConfig): Promise; + + /** + * 发送消息到 Agent(无状态) + */ + sendMessage(request: AgentRequest, config: AgentProcessConfig): SumiReadableStream; + + /** + * 请求权限确认 + */ + requestPermission(toolCallUpdate: ToolCallUpdate): Promise; + + /** + * 取消请求 + */ + cancelRequest(sessionId: string): Promise; + + /** + * 停止 Agent 进程 + */ + stopAgent(): Promise; + + /** + * 清理所有资源 + */ + dispose(): Promise; + + /** + * 获取当前 Agent Session 信息 + */ + getSessionInfo(): AgentSessionInfo | null; + + createSession(config: AgentProcessConfig): Promise<{ sessionId: string }>; + + /** + * 列出所有 ACP Agent 会话 + */ + listSessions(params?: ListSessionsRequest): Promise; + + /** + * 切换 Session 模式 + */ + setSessionMode(params: SetSessionModeRequest): Promise; + + /** + * 释放指定 Session 的资源(包括终端等) + */ + disposeSession(sessionId: string): Promise; + + /** + * 获取 initialize 协商时存储的 Session 模式 + */ + getAvailableModes(): Promise; +} + +/** + * 无状态的 ACP Agent 服务 + * + * 设计原则: + * 1. 只维护单一 Agent 进程实例 + * 2. 负责启动/停止 Agent 进程、转发请求、流式返回响应 + */ +@Injectable() +export class AcpAgentService implements IAcpAgentService { + @Autowired(AcpCliClientServiceToken) + private clientService: IAcpCliClientService; + + @Autowired(CliAgentProcessManagerToken) + private processManager: ICliAgentProcessManager; + + @Autowired(AcpTerminalHandler) + private terminalHandler: AcpTerminalHandler; + + @Autowired(AcpAgentRequestHandler) + private agentRequestHandler: AcpAgentRequestHandler; + + @Autowired(AppConfig) + private appConfig: AppConfig; + + @Autowired(INodeLogger) + private readonly logger: INodeLogger; + + // 当前 Agent Session 信息 + private sessionInfo: AgentSessionInfo | null = null; + + // 全局 Agent 进程 ID(单一实例) + private currentProcessId: string | null = null; + + // 当前活跃的通知处理器和 stream + private currentNotificationHandler: { + unsubscribe: () => void; + stream: SumiReadableStream; + sessionId: string; + pendingToolCalls: Map>; + } | null = null; + + // 确保初始化只执行一次 + private initializingPromise: Promise | null = null; + + // 跨所有监听器追踪正在进行权限请求的 toolCallId,防止重复弹窗 + private inFlightPermissions = new Set(); + + async createSession(config: AgentProcessConfig): Promise<{ sessionId: string }> { + await this.ensureConnected(config); + const res = await this.clientService.newSession({ cwd: config.workspaceDir, mcpServers: [] }); + return { sessionId: res.sessionId }; + } + /** + * 确保 Agent 进程已连接并初始化,复用现有连接或启动新进程 + */ + private async ensureConnected(config: AgentProcessConfig): Promise { + if (this.currentProcessId && this.processManager.isRunning()) { + return this.currentProcessId; + } + + if (this.currentProcessId && !this.processManager.isRunning()) { + this.logger?.warn('[ensureConnected] Process not running, clearing old state'); + this.currentProcessId = null; + } + + const agentConfig = getAgentConfig(config.agentType || DEFAULT_AGENT_TYPE); + const { processId, stdout, stdin } = await this.processManager.startAgent( + agentConfig.command, + agentConfig.args, + config.env ?? {}, + config.workspaceDir, + ); + + this.logger?.log(`[ensureConnected] Setting up transport for process ${processId}`); + this.clientService.setTransport(stdout, stdin); + await this.clientService.initialize(); + this.currentProcessId = processId; + return processId; + } + + /** + * 获取当前 Agent Session 信息 + */ + getSessionInfo(): AgentSessionInfo | null { + return this.sessionInfo; + } + + async initializeAgent(config: AgentProcessConfig): Promise { + if (this.sessionInfo && this.currentProcessId && this.processManager.isRunning()) { + return this.sessionInfo; + } + + if (this.sessionInfo && !this.currentProcessId) { + this.sessionInfo = null; + this.initializingPromise = null; + } + + if (this.initializingPromise) { + return this.initializingPromise; + } + + this.initializingPromise = (async () => { + const processId = await this.ensureConnected(config); + + const newSessionRequest: NewSessionRequest = { + cwd: config.workspaceDir, + mcpServers: [], + }; + + const newSessionResponse = await this.clientService.newSession(newSessionRequest); + + this.sessionInfo = { + sessionId: newSessionResponse.sessionId, + processId, + modes: (newSessionResponse.modes?.availableModes ?? []) as SessionMode[], + status: 'ready', + }; + + this.currentProcessId = processId; + + return this.sessionInfo; + })(); + + try { + const result = await this.initializingPromise; + return result; + } finally { + this.initializingPromise = null; + } + } + + /** + * 加载已有 Agent Session + */ + async loadSession(sessionId: string, config: AgentProcessConfig): Promise { + const processId = await this.ensureConnected(config); + + const historyUpdates: SessionNotification[] = []; + + // 设置临时通知处理器来收集 session/update + const tempHandler = (notification: SessionNotification) => { + if (notification.sessionId === sessionId && notification.update) { + historyUpdates.push(notification); + } + }; + + // 订阅临时通知处理器 + const unsubscribe = this.clientService.onNotification(tempHandler); + + const loadPromise = new Promise(async (resolve, reject) => { + const timeout = setTimeout(() => { + unsubscribe(); + reject(new Error(`Session load timeout for ${sessionId}`)); + }, 60000); + + try { + const loadRequest: LoadSessionRequest = { + sessionId, + cwd: config.workspaceDir, + mcpServers: [], + }; + + await this.clientService.loadSession(loadRequest); + + // 等待延迟的 session/update 通知 + await new Promise((delayResolve) => setTimeout(delayResolve, 500)); + + clearTimeout(timeout); + unsubscribe(); + resolve(); + } catch (error) { + clearTimeout(timeout); + unsubscribe(); + reject(error); + resolve(); + } + }); + + await loadPromise; + + const modes: SessionMode[] = []; + for (const notification of historyUpdates) { + const update = notification.update as any; + if (update?.currentModeId) { + const existingMode = modes.find((m) => m.id === update.currentModeId); + if (!existingMode) { + modes.push({ id: update.currentModeId, name: update.currentModeId }); + } + } + } + + this.sessionInfo = { + sessionId, + processId, + modes, + status: 'ready', + }; + + this.currentProcessId = processId; + + const result: SessionLoadResult = { + sessionId, + processId, + modes, + status: 'ready', + historyUpdates, + }; + + return result; + } + + /** + * 发送消息到 Agent(无状态) + */ + sendMessage(request: AgentRequest): SumiReadableStream { + const stream = new SumiReadableStream(); + + if (!this.currentProcessId) { + stream.emitError(new Error('Agent process not initialized')); + return stream; + } + + const promptBlocks = this.buildPromptBlocks(request.prompt, request.images); + + const promptRequest = { + sessionId: request.sessionId, + prompt: promptBlocks, + }; + + const pendingToolCalls = new Map>(); + + const unsubscribe = this.clientService.onNotification((notification: SessionNotification) => { + if (notification.sessionId !== request.sessionId) { + return; + } + + this.handleNotification(notification, stream, pendingToolCalls); + }); + + // 流结束时清理 + stream.onEnd(() => { + unsubscribe(); + this.currentNotificationHandler = null; + }); + stream.onError((error) => { + unsubscribe(); + this.currentNotificationHandler = null; + }); + + // 保存当前处理器信息 + this.currentNotificationHandler = { + unsubscribe, + stream, + sessionId: request.sessionId, + pendingToolCalls, + }; + + this.sendPrompt(promptRequest, stream, pendingToolCalls); + + return stream; + } + + /** + * 异步发送 prompt(内部使用) + */ + private async sendPrompt( + promptRequest: { sessionId: string; prompt: ContentBlock[] }, + stream: SumiReadableStream, + pendingToolCalls: Map>, + ): Promise { + try { + await this.clientService.prompt(promptRequest); + await this.waitForPendingToolCalls(stream, pendingToolCalls); + } catch (error) { + stream.emitError(error instanceof Error ? error : new Error(String(error))); + } + } + + /** + * 等待所有 pending tool calls 完成 + */ + private async waitForPendingToolCalls( + stream: SumiReadableStream, + pendingToolCalls: Map>, + ): Promise { + const timeout = 60000; // 60 秒,与权限对话框 timeout 一致 + const startTime = Date.now(); + + // 等待所有 pending tool calls 完成或超时 + while (pendingToolCalls.size > 0) { + if (Date.now() - startTime > timeout) { + this.logger?.warn(`waitForPendingToolCalls timeout after ${timeout}ms`); + break; + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + + stream.emitData({ type: 'done', content: '' }); + stream.end(); + } + + /** + * 处理通知 + */ + private handleNotification( + notification: SessionNotification, + stream: SumiReadableStream, + pendingToolCalls: Map>, + ): void { + const update = notification.update; + + switch (update.sessionUpdate) { + case 'agent_thought_chunk': { + const content = update.content; + if (content.type === 'text') { + stream.emitData({ + type: 'thought', + content: content.text, + }); + } + break; + } + + case 'agent_message_chunk': { + const content = update.content; + if (content.type === 'text') { + stream.emitData({ + type: 'message', + content: content.text, + }); + } + break; + } + + case 'tool_call': { + // 异步处理 tool call,保存 Promise 供 sendPrompt 等待 + this.handleToolCallWithPermission(update, stream, pendingToolCalls); + break; + } + + case 'tool_call_update': { + if (update.content) { + for (const content of update.content) { + if (content.type === 'diff') { + stream.emitData({ + type: 'tool_result', + content: `Modified ${content.path}`, + }); + } + } + } + break; + } + + default: + this.logger?.log(`Unhandled session update type: ${update.sessionUpdate}`); + break; + } + } + + /** + * 请求权限确认 + */ + async requestPermission(toolCallUpdate: ToolCallUpdate): Promise { + const request: RequestPermissionRequest = { + sessionId: this.sessionInfo?.sessionId || '', + toolCall: { + toolCallId: toolCallUpdate.toolCallId, + title: toolCallUpdate.title, + kind: toolCallUpdate.kind, + status: 'pending', + rawInput: toolCallUpdate.rawInput, + locations: toolCallUpdate.locations, + }, + options: [ + { optionId: 'allow_once', name: 'Allow Once', kind: 'allow_once' }, + { optionId: 'allow_always', name: 'Allow Always', kind: 'allow_always' }, + { optionId: 'reject_once', name: 'Reject Once', kind: 'reject_once' }, + ], + }; + + try { + const response = await this.agentRequestHandler.handlePermissionRequest(request); + + if (response.outcome.outcome === 'selected') { + const optionId = response.outcome.optionId; + const approved = optionId.includes('allow'); + return { approved, input: { optionId } }; + } else { + return { approved: false }; + } + } catch (error) { + this.logger?.error('Permission request failed:', error); + return { approved: false }; + } + } + + /** + * 取消请求 + */ + async cancelRequest(sessionId: string): Promise { + if (!this.currentProcessId) { + this.logger?.warn('cancelRequest: Agent process not initialized'); + return; + } + + const cancelNotification: CancelNotification = { + sessionId, + }; + + try { + await this.clientService.cancel(cancelNotification); + } catch (error) {} + } + + async listSessions(params?: ListSessionsRequest): Promise { + return this.clientService.listSessions(params); + } + + async setSessionMode(params: SetSessionModeRequest): Promise { + await this.clientService.setSessionMode(params); + } + + async disposeSession(sessionId: string): Promise { + await this.terminalHandler.releaseSessionTerminals(sessionId); + } + + async getAvailableModes() { + return this.clientService.getSessionModes(); + } + + /** + * 停止 Agent 进程 + */ + async stopAgent(): Promise { + if (!this.currentProcessId) { + return; + } + + await this.processManager.stopAgent(); + + await this.clientService.close(); + + this.sessionInfo = null; + this.currentProcessId = null; + this.initializingPromise = null; + } + + /** + * 清理所有资源 + */ + async dispose(): Promise { + const stackTrace = new Error('dispose called').stack; + this.logger?.error('[AcpAgentService] dispose called', stackTrace); + + if (this.currentNotificationHandler) { + // 不需要手动 reject Promise,因为 Promise 已经创建完成 + // 只需清理通知处理器和 stream + this.currentNotificationHandler.stream.end(); + this.currentNotificationHandler.unsubscribe(); + this.currentNotificationHandler = null; + } + + await this.stopAgent(); + + await this.clientService.close(); + + await this.processManager.killAllAgents(); + + this.initializingPromise = null; + this.sessionInfo = null; + this.currentProcessId = null; + } + + private buildPromptBlocks(input: string, images?: string[]): ContentBlock[] { + const blocks: ContentBlock[] = []; + + blocks.push({ + type: 'text', + text: input, + }); + + if (images && images.length > 0) { + for (const imageData of images) { + const { mimeType, base64Data } = this.parseDataUrl(imageData); + blocks.push({ + type: 'image', + data: base64Data, + mimeType, + }); + } + } + + return blocks; + } + + private parseDataUrl(dataUrl: string): { mimeType: string; base64Data: string } { + if (dataUrl.startsWith('data:')) { + const matches = dataUrl.match(/^data:([^;]+);base64,(.+)$/); + if (matches) { + return { mimeType: matches[1], base64Data: matches[2] }; + } + } + // 默认返回 + return { mimeType: 'image/jpeg', base64Data: dataUrl }; + } + + /** + * 处理 tool_call 并请求权限确认 + */ + private async handleToolCallWithPermission( + toolCallUpdate: ToolCallUpdate, + stream: SumiReadableStream, + pendingToolCalls: Map>, + ): Promise { + const toolCallId = toolCallUpdate.toolCallId; + + if (this.inFlightPermissions.has(toolCallId)) { + return; + } + this.inFlightPermissions.add(toolCallId); + + // 创建 Promise 并存储,供 sendPrompt 中的 waitForPendingToolCalls 等待 + const permissionPromise = this.requestPermission(toolCallUpdate).then( + (result) => { + if (result.approved) { + this.logger?.log(`Tool call "${toolCallUpdate.title}" approved`); + } else { + this.logger?.log(`Tool call "${toolCallUpdate.title}" denied`); + if (this.sessionInfo) { + this.cancelRequest(this.sessionInfo.sessionId).catch(() => {}); + } + } + return result.approved; + }, + (error) => { + this.logger?.error(`Failed to get permission for tool call: ${toolCallUpdate.title}`, error); + throw error; + }, + ); + + pendingToolCalls.set(toolCallId, permissionPromise); + + stream.emitData({ + type: 'tool_call', + content: toolCallUpdate.title || '', + toolCall: { + name: toolCallUpdate.title || '', + input: (toolCallUpdate.rawInput as Record) || {}, + }, + }); + + try { + await permissionPromise; + // 完成后从 Map 中移除 + pendingToolCalls.delete(toolCallId); + } catch (error) { + // 错误时也从 Map 中移除 + pendingToolCalls.delete(toolCallId); + } finally { + this.inFlightPermissions.delete(toolCallId); + } + } +} diff --git a/packages/ai-native/src/node/acp/acp-cli-back.service.ts b/packages/ai-native/src/node/acp/acp-cli-back.service.ts new file mode 100644 index 0000000000..00b55bac9b --- /dev/null +++ b/packages/ai-native/src/node/acp/acp-cli-back.service.ts @@ -0,0 +1,348 @@ +import { Autowired, Injectable } from '@opensumi/di'; +import { + CancellationToken, + IAIBackService, + IAIBackServiceOption, + IAIBackServiceResponse, + IChatContent, + IChatProgress, + IChatReasoning, + ListSessionsRequest, + ListSessionsResponse, + SessionNotification, + SetSessionModeRequest, +} from '@opensumi/ide-core-common'; +import { AgentProcessConfig } from '@opensumi/ide-core-common/lib/types/ai-native/agent-types'; +import { INodeLogger } from '@opensumi/ide-core-node'; +import { SumiReadableStream } from '@opensumi/ide-utils/lib/stream'; + +import { + AcpAgentServiceToken, + AgentRequest, + AgentSessionInfo, + AgentUpdate, + IAcpAgentService, + SimpleMessage, +} from './acp-agent.service'; + +import type { CoreMessage } from 'ai'; + +export const AcpCliBackServiceToken = Symbol('AcpCliBackServiceToken'); + +/** + * Type guard to check if a value is a valid CoreMessage + */ +function isCoreMessage(msg: unknown): msg is CoreMessage { + if (!msg || typeof msg !== 'object') { + return false; + } + return 'role' in msg && 'content' in msg; +} + +/** + * Type guard to check if a content part is a text part + */ +function isTextContentPart(part: unknown): part is { type: 'text'; text: string } { + return ( + typeof part === 'object' && + part !== null && + 'type' in part && + (part as { type: string }).type === 'text' && + 'text' in part + ); +} + +function convertToSimpleMessage(msg?: CoreMessage): SimpleMessage { + if (!msg || !isCoreMessage(msg)) { + return { + role: 'user', + content: '', + }; + } + + let content: string; + if (typeof msg.content === 'string') { + content = msg.content; + } else if (Array.isArray(msg.content)) { + content = msg.content + .filter(isTextContentPart) + .map((part) => part.text) + .join('\n'); + } else { + content = String(msg.content ?? ''); + } + + return { + role: msg.role ?? 'user', + content, + }; +} + +function convertMessageHistory(history?: CoreMessage[]): SimpleMessage[] | undefined { + if (!history || history[0] === null) { + return undefined; + } + return history.map(convertToSimpleMessage); +} + +@Injectable() +export class AcpCliBackService implements IAIBackService { + @Autowired(AcpAgentServiceToken) + private agentService: IAcpAgentService; + + @Autowired(INodeLogger) + private readonly logger: INodeLogger; + + private isDisposing = false; + + // private registerProcessExitHandlers(): void { + // process.once('SIGTERM', () => { + // this.dispose().then(() => { + // process.exit(0); + // }); + // }); + + // process.once('SIGINT', () => { + // this.dispose().then(() => { + // process.exit(0); + // }); + // }); + // } + + async createSession(config: AgentProcessConfig): Promise<{ sessionId: string }> { + await this.ensureAgentInitialized(config); + return this.agentService.createSession(config); + } + + private async ensureAgentInitialized(config: AgentProcessConfig): Promise { + const existingSession = this.agentService.getSessionInfo(); + if (existingSession) { + return existingSession; + } + return this.agentService.initializeAgent(config); + } + + async request( + input: string, + options: IAIBackServiceOption, + cancelToken?: CancellationToken, + ): Promise { + return { + errorCode: -1, + errorMsg: 'request() is not supported. ', + } as IAIBackServiceResponse; + } + + async requestStream( + input: string, + options: IAIBackServiceOption, + cancelToken?: CancellationToken, + ): Promise> { + return this.agentRequestStream(input, options, cancelToken); + } + + private agentRequestStream( + input: string, + options: IAIBackServiceOption, + cancelToken?: CancellationToken, + ): SumiReadableStream { + const stream = new SumiReadableStream(); + this.setupAgentStream(options.agentSessionConfig!, input, options, stream, cancelToken); + return stream; + } + + private async setupAgentStream( + config: AgentProcessConfig, + input: string, + options: IAIBackServiceOption, + stream: SumiReadableStream, + cancelToken?: CancellationToken, + ): Promise { + try { + if (!options.agentSessionConfig) { + throw Error('agentSessionConfig is required'); + } + + const sessionInfo = await this.ensureAgentInitialized(options.agentSessionConfig); + const sessionId = options.sessionId || sessionInfo.sessionId; + + const request: AgentRequest = { + sessionId, + prompt: input, + images: options.images, + history: convertMessageHistory(options.history), + }; + + const agentStream = this.agentService.sendMessage(request, config); + + cancelToken?.onCancellationRequested(async () => { + await this.agentService.cancelRequest(sessionId); + stream.end(); + }); + + agentStream.onData((update: AgentUpdate) => { + const progress = this.convertAgentUpdateToChatProgress(update); + if (progress) { + stream.emitData(progress); + } + if (update.type === 'done') { + stream.end(); + } + }); + + agentStream.onError((error) => { + stream.emitError(error instanceof Error ? error : new Error(String(error))); + }); + } catch (error) { + stream.emitError(error instanceof Error ? error : new Error(String(error))); + } + } + + private convertAgentUpdateToChatProgress(update: AgentUpdate): IChatProgress | null { + switch (update.type) { + case 'thought': + return { + kind: 'reasoning', + content: update.content, + } as IChatReasoning; + case 'message': + return { + kind: 'content', + content: update.content, + } as IChatContent; + case 'tool_call': + return null; + case 'tool_result': + return { + kind: 'content', + content: update.content, + } as IChatContent; + case 'done': + return null; + default: + return null; + } + } + + async loadAgentSession( + config: AgentProcessConfig, + sessionId: string, + ): Promise<{ + sessionId: string; + messages: Array<{ + role: 'user' | 'assistant'; + content: string; + timestamp?: number; + }>; + }> { + try { + const result = await this.agentService.loadSession(sessionId, config); + const messages = this.convertSessionUpdatesToMessages(result.historyUpdates); + return { + sessionId, + messages, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + this.logger.error(`Failed to load session ${sessionId}:`, errorMessage); + + // 抛出错误,让调用方感知实际错误 + throw new Error(`Failed to load session ${sessionId}: ${errorMessage}`); + } + } + + private convertSessionUpdatesToMessages( + updates: SessionNotification[], + ): Array<{ role: 'user' | 'assistant'; content: string; timestamp?: number }> { + const messages: Array<{ role: 'user' | 'assistant'; content: string; timestamp?: number }> = []; + + for (const notification of updates) { + const update = notification.update as any; + if (!update) { + continue; + } + + switch (update.sessionUpdate) { + case 'user_message_chunk': { + const content = update.content; + if (content?.type === 'text') { + messages.push({ + role: 'user', + content: content.text, + }); + } + break; + } + case 'agent_message_chunk': { + const content = update.content; + if (content?.type === 'text') { + messages.push({ + role: 'assistant', + content: content.text, + }); + } + break; + } + default: + break; + } + } + + return messages; + } + + async disposeSession(sessionId: string): Promise { + await this.cancelSession(sessionId); + try { + await this.agentService.disposeSession(sessionId); + } catch (error) { + this.logger.error(`Failed to release terminals for session ${sessionId}:`, error); + } + } + + async cancelSession(sessionId: string): Promise { + await this.agentService.cancelRequest(sessionId); + } + + async setSessionMode(sessionId: string, modeId: string): Promise { + const modeRequest: SetSessionModeRequest = { + sessionId, + modeId, + }; + try { + await this.agentService.setSessionMode(modeRequest); + } catch (error) { + this.logger.error(`Failed to switch mode to ${modeId}:`, error); + throw error; + } + } + + async listSessions(config: AgentProcessConfig): Promise { + const listParams: ListSessionsRequest = { + cwd: config.workspaceDir, + }; + await this.ensureAgentInitialized(config); + + try { + const response = await this.agentService.listSessions(listParams); + return { + sessions: response.sessions, + nextCursor: response.nextCursor, + }; + } catch (error) { + this.logger.error('Failed to list sessions:', error); + throw error; + } + } + + async dispose(): Promise { + this.logger?.log('[AcpCliBackService] Already disposin'); + if (this.isDisposing) { + this.logger?.log('[AcpCliBackService] Already disposing, skipping...'); + return; + } + this.isDisposing = true; + await this.agentService.dispose(); + this.logger?.log('[AcpCliBackService] Disposed successfully'); + } +} diff --git a/packages/ai-native/src/node/acp/acp-cli-client.service.ts b/packages/ai-native/src/node/acp/acp-cli-client.service.ts new file mode 100644 index 0000000000..4c51ab9b81 --- /dev/null +++ b/packages/ai-native/src/node/acp/acp-cli-client.service.ts @@ -0,0 +1,481 @@ +/** + * ACP CLI 客户端服务 - 基于 NDJSON 格式的 JSON-RPC 2.0 传输层实现 + */ +import { Autowired, Injectable } from '@opensumi/di'; +import { IAcpCliClientService } from '@opensumi/ide-core-common'; +import { INodeLogger, Implementation } from '@opensumi/ide-core-node'; + +import { AcpAgentRequestHandler } from './handlers/agent-request.handler'; + +import type { + AgentCapabilities, + AuthMethod, + AuthenticateRequest, + AuthenticateResponse, + CancelNotification, + ExtendedInitializeResponse, + InitializeRequest, + ListSessionsRequest, + ListSessionsResponse, + LoadSessionRequest, + LoadSessionResponse, + NewSessionRequest, + NewSessionResponse, + PromptRequest, + PromptResponse, + SessionModeState, + SessionNotification, + SetSessionModeRequest, + SetSessionModeResponse, +} from '@opensumi/ide-core-common/lib/types/ai-native/acp-types'; + +export const ACP_PROTOCOL_VERSION = 1; + +@Injectable() +export class AcpCliClientService implements IAcpCliClientService { + private stdout: NodeJS.ReadableStream | null = null; + private stdin: NodeJS.WritableStream | null = null; + private connected = false; + private requestId = 0; + private buffer = ''; + + private notificationHandlers: ((notification: SessionNotification) => void)[] = []; + + private negotiatedProtocolVersion: number | null = null; + private agentCapabilities: AgentCapabilities | null = null; + private agentInfo: Implementation | null = null; + private authMethods: AuthMethod[] = []; + private sessionModes: SessionModeState | null = null; + + @Autowired(INodeLogger) + private readonly logger: INodeLogger; + + @Autowired(AcpAgentRequestHandler) + private agentRequestHandler: AcpAgentRequestHandler; + + setTransport(stdout: NodeJS.ReadableStream, stdin: NodeJS.WritableStream): void { + this.logger?.log('[ACP] Setting up transport streams'); + + for (const [, pending] of this.pendingRequests) { + pending.reject(new Error('Transport reset')); + } + this.pendingRequests.clear(); + + if (this.stdout) { + this.logger?.log('[ACP] Removing old stdout listeners'); + this.stdout.removeAllListeners(); + } + + if (this.stdin) { + this.logger?.log('[ACP] Closing old stdin'); + try { + this.stdin.end(); + } catch (_) {} + } + + this.negotiatedProtocolVersion = null; + this.agentCapabilities = null; + this.agentInfo = null; + this.authMethods = []; + this.sessionModes = null; + + this.stdout = stdout; + this.stdin = stdin; + this.connected = false; + + this.logger?.log('[ACP] Registering stdout listeners'); + + const dataHandler = (data: Buffer) => { + this.handleData(data.toString('utf8')); + }; + this.stdout.on('data', dataHandler); + + this.stdout.on('end', () => { + this.logger?.error('[ACP] stdout ended - connection lost'); + this.handleDisconnect(); + }); + + this.stdout.on('error', (err) => { + this.logger?.error('[ACP] stdout error - connection lost:', err); + this.handleDisconnect(); + }); + + this.buffer = ''; + + this.connected = true; + this.logger?.log('[ACP] Transport setup complete, connected=true'); + } + + async initialize(params?: InitializeRequest): Promise { + if (!this.stdin || !this.stdout) { + throw new Error('Transport not set up'); + } + + const initParams: InitializeRequest = params || { + protocolVersion: ACP_PROTOCOL_VERSION, + clientCapabilities: { + fs: { + readTextFile: true, + writeTextFile: true, + }, + terminal: true, + }, + clientInfo: { + name: 'opensumi', + title: 'OpenSumi IDE', + version: '3.0.0', + }, + }; + + initParams.protocolVersion = initParams.protocolVersion || ACP_PROTOCOL_VERSION; + + const response = await this.sendRequest('initialize', initParams); + + if (response.protocolVersion !== initParams.protocolVersion) { + this.logger?.warn( + `Agent responded with different protocol version: ${response.protocolVersion}. ` + + `Client requested: ${initParams.protocolVersion}`, + ); + + if (response.protocolVersion > ACP_PROTOCOL_VERSION) { + await this.close(); + throw new Error( + 'Unsupported protocol version: ' + + response.protocolVersion + + '. ' + + 'This client supports up to version ' + + ACP_PROTOCOL_VERSION + + '. ' + + 'Please update the client to use the latest version.', + ); + } + } + + this.negotiatedProtocolVersion = response.protocolVersion; + + if (response.agentCapabilities) { + this.agentCapabilities = response.agentCapabilities; + } + + if (response.agentInfo) { + this.agentInfo = response.agentInfo; + } + + if (response.authMethods && response.authMethods.length > 0) { + this.authMethods = response.authMethods; + } + + if (response.modes) { + this.sessionModes = response.modes; + } + + return response; + } + + async authenticate(params: AuthenticateRequest): Promise { + return this.sendRequest('authenticate', params); + } + + async newSession(params: NewSessionRequest): Promise { + return this.sendRequest('session/new', params); + } + + async loadSession(params: LoadSessionRequest): Promise { + return this.sendRequest('session/load', params); + } + + async listSessions(params?: ListSessionsRequest): Promise { + return this.sendRequest('session/list', params); + } + + async prompt(params: PromptRequest): Promise { + return this.sendRequest('session/prompt', params); + } + + async cancel(params: CancelNotification): Promise { + this.sendNotification('session/cancel', params); + } + + async setSessionMode(params: SetSessionModeRequest): Promise { + return this.sendRequest('session/set_mode', params); + } + + onNotification(handler: (notification: SessionNotification) => void): () => void { + this.notificationHandlers.push(handler); + return () => { + const index = this.notificationHandlers.indexOf(handler); + if (index > -1) { + this.notificationHandlers.splice(index, 1); + } + }; + } + + async close(): Promise { + this.connected = false; + + this.negotiatedProtocolVersion = null; + this.agentCapabilities = null; + this.agentInfo = null; + this.authMethods = []; + this.sessionModes = null; + + this.notificationHandlers = []; + + if (this.stdout) { + this.stdout.removeAllListeners(); + } + + this.stdout = null; + this.stdin = null; + this.buffer = ''; + } + + isConnected(): boolean { + return this.connected; + } + + private pendingRequests = new Map< + string | number, + { + resolve: (value: unknown) => void; + reject: (error: Error) => void; + } + >(); + + private requestTimeoutMs = 120000; + + private async sendRequest(method: string, params: unknown): Promise { + if (!this.stdin) { + throw new Error('Not connected'); + } + + const id = ++this.requestId; + + this.logger?.log(`[ACP] Sending request: ${method} (id=${id}) ${JSON.stringify(params)}`); + + return new Promise((resolve, reject) => { + this.pendingRequests.set(id, { + resolve: resolve as (value: unknown) => void, + reject, + }); + + const message = { jsonrpc: '2.0', id, method, params }; + const json = JSON.stringify(message); + + this.stdin!.write(json + '\n'); + this.logger?.debug(`[ACP] Sent JSON: ${json.substring(0, 200)}`); + }); + } + + private sendNotification(method: string, params?: unknown): void { + if (!this.stdin) { + throw new Error('Not connected'); + } + + const message = { jsonrpc: '2.0', method, params }; + const json = JSON.stringify(message); + + this.stdin.write(json + '\n'); + } + + private handleData(dataStr: string): void { + this.buffer += dataStr; + + const lines = this.buffer.split('\n'); + this.buffer = lines.pop() || ''; + + for (const line of lines) { + const trimmedLine = line.trim(); + if (!trimmedLine) { + continue; + } + + try { + const message = JSON.parse(trimmedLine); + this.logger?.debug('[ACP] Parsed message:', JSON.stringify(message).substring(0, 400)); + this.handleMessage(message); + } catch (error) { + this.logger?.error('Failed to parse ACP JSON-RPC message:', { + line: trimmedLine, + error, + }); + } + } + } + + private handleMessage(message: any): void { + if ('id' in message && ('result' in message || 'error' in message)) { + this.handleResponse(message); + } else if ('id' in message && 'method' in message) { + this.handleIncomingRequest(message); + } else if ('method' in message && !('id' in message)) { + this.handleIncomingNotification(message); + } else { + throw new Error(`Invalid ACP JSON-RPC message: ${JSON.stringify(message)}`); + } + } + + private handleResponse(response: { + jsonrpc: '2.0'; + id: string | number; + result?: unknown; + error?: { code: number; message: string; data?: unknown }; + }): void { + const pending = this.pendingRequests.get(response.id); + if (pending) { + this.logger?.log(`[ACP] Matching response to request id=${response.id}`); + this.pendingRequests.delete(response.id); + + if (response.error) { + this.logger?.error(`[ACP] Request id=${response.id} failed:`, response.error); + pending.reject(this.createError(response.error)); + } else { + this.logger?.log(`[ACP] Request id=${response.id} succeeded`); + pending.resolve(response.result); + } + } else { + this.logger?.warn( + `Response received for unknown request id: ${response.id}. ` + 'This may be a late arrival after timeout.', + ); + } + } + + private async handleIncomingRequest(message: { + jsonrpc: '2.0'; + id: string | number; + method: string; + params?: unknown; + }): Promise { + try { + let result: unknown; + switch (message.method) { + case 'fs/read_text_file': + result = await this.agentRequestHandler.handleReadTextFile(message.params as any); + break; + case 'fs/write_text_file': + result = await this.agentRequestHandler.handleWriteTextFile(message.params as any); + break; + case 'session/request_permission': + result = await this.agentRequestHandler.handlePermissionRequest(message.params as any); + break; + case 'terminal/create': + result = await this.agentRequestHandler.handleCreateTerminal(message.params as any); + break; + case 'terminal/output': + result = await this.agentRequestHandler.handleTerminalOutput(message.params as any); + break; + case 'terminal/wait_for_exit': + result = await this.agentRequestHandler.handleWaitForTerminalExit(message.params as any); + break; + case 'terminal/kill': + result = await this.agentRequestHandler.handleKillTerminal(message.params as any); + break; + case 'terminal/release': + result = await this.agentRequestHandler.handleReleaseTerminal(message.params as any); + break; + default: + this.logger?.warn(`Unknown incoming request method: ${message.method}`); + this.sendMessage({ + jsonrpc: '2.0', + id: message.id, + error: { code: -32601, message: `Method not found: ${message.method}` }, + }); + return; + } + this.sendMessage({ jsonrpc: '2.0', id: message.id, result }); + } catch (err: any) { + this.sendMessage({ + jsonrpc: '2.0', + id: message.id, + error: { code: err.code || -32603, message: err.message || 'Internal error' + JSON.stringify(message) }, + }); + } + } + + private handleIncomingNotification(message: { jsonrpc: '2.0'; method: string; params?: unknown }): void { + if (message.method === 'session/update') { + const notification = message.params as SessionNotification; + + if (notification.update?.sessionUpdate === 'current_mode_update' && notification.update?.currentModeId) { + if (this.sessionModes) { + this.sessionModes.currentModeId = notification.update.currentModeId; + } else { + this.logger?.warn('[ACP] Received current_mode_update but sessionModes is not initialized'); + } + } + + for (const handler of this.notificationHandlers) { + handler(notification); + } + } + } + + private sendMessage(message: { + jsonrpc: '2.0'; + id?: string | number; + method?: string; + params?: unknown; + result?: unknown; + error?: { code: number; message: string; data?: unknown }; + }): void { + if (!this.stdin) { + throw new Error('Not connected'); + } + + const json = JSON.stringify(message); + + this.stdin.write(json + '\n'); + } + + public handleDisconnect(): void { + if (!this.connected) { + return; + } + + this.logger?.log('[ACP] Handling disconnect'); + + this.connected = false; + + this.negotiatedProtocolVersion = null; + this.agentCapabilities = null; + this.agentInfo = null; + this.authMethods = []; + this.sessionModes = null; + + for (const [, pending] of this.pendingRequests) { + pending.reject(new Error('Connection lost')); + } + this.pendingRequests.clear(); + + this.logger?.warn('[ACP] ACP connection lost'); + } + + private createError(error: { code: number; message: string; data?: unknown }): Error { + const err = new Error(error.message); + (err as any).code = error.code; + if (error.data !== undefined) { + (err as any).data = error.data; + } + return err; + } + + getNegotiatedProtocolVersion(): number | null { + return this.negotiatedProtocolVersion; + } + + getAgentCapabilities(): AgentCapabilities | null { + return this.agentCapabilities; + } + + getAgentInfo(): Implementation | null { + return this.agentInfo; + } + + getAuthMethods(): AuthMethod[] { + return this.authMethods; + } + + getSessionModes(): SessionModeState | null { + return this.sessionModes; + } +} diff --git a/packages/ai-native/src/node/acp/acp-permission-caller.service.ts b/packages/ai-native/src/node/acp/acp-permission-caller.service.ts new file mode 100644 index 0000000000..2e7fd0056d --- /dev/null +++ b/packages/ai-native/src/node/acp/acp-permission-caller.service.ts @@ -0,0 +1,189 @@ +import { Autowired, Injectable } from '@opensumi/di'; +import { RPCService } from '@opensumi/ide-connection'; +import { INodeLogger } from '@opensumi/ide-core-node'; + +import type { + AcpPermissionDecision, + AcpPermissionDialogParams, + IAcpPermissionCaller, + IAcpPermissionService, + PermissionOption, + PermissionOptionKind, + RequestPermissionRequest, + RequestPermissionResponse, +} from '@opensumi/ide-core-common/lib/types/ai-native/acp-types'; + +export const AcpPermissionCallerManagerToken = Symbol('AcpPermissionCallerManagerToken'); + +/** + * ACP Permission Caller Manager + * + */ +@Injectable() +export class AcpPermissionCallerManager extends RPCService implements IAcpPermissionCaller { + @Autowired(INodeLogger) + private readonly logger: INodeLogger; + + /** + * 当前活跃的 RPC 客户端(所有连接共享) + * + */ + private static currentRpcClient: IAcpPermissionService | null = null; + + private clientId: string | undefined; + + /** + * 设置连接 clientId + * + * 注意:框架调用 setConnectionClientId 后才设置 rpcClient, + * 因此需要使用微任务延迟赋值,确保 rpcClient 已经准备好 + */ + setConnectionClientId(clientId: string): void { + this.clientId = clientId; + + Promise.resolve().then(() => { + AcpPermissionCallerManager.currentRpcClient = this.client || null; + }); + } + + removeConnectionClientId(clientId: string): void { + if (this.clientId === clientId) { + if (AcpPermissionCallerManager.currentRpcClient === this.client) { + AcpPermissionCallerManager.currentRpcClient = null; + } + this.clientId = undefined; + } + } + + /** + * Request permission from the user via browser dialog + */ + async requestPermission(request: RequestPermissionRequest): Promise { + const rpcClient = AcpPermissionCallerManager.currentRpcClient || this.client; + + if (!rpcClient) { + throw new Error('[ACP Permission Caller] No active RPC client available'); + } + + const dialogParams: AcpPermissionDialogParams = { + requestId: `${request.sessionId}:${request.toolCall.toolCallId}`, + sessionId: request.sessionId, + title: request.toolCall.title ?? 'Permission Request', + kind: request.toolCall.kind ?? undefined, + content: this.buildPermissionContent(request), + locations: request.toolCall.locations?.map((loc) => ({ + path: loc.path, + line: loc.line ?? undefined, + })), + options: this.sortOptionsByKind(request.options), + timeout: 60000, + }; + + const decision = await rpcClient.$showPermissionDialog(dialogParams); + + return this.buildPermissionResponse(decision, request.options); + } + + /** + * Cancel a pending permission request + */ + async cancelRequest(requestId: string): Promise { + try { + const rpcClient = AcpPermissionCallerManager.currentRpcClient || this.client; + if (rpcClient) { + await rpcClient.$cancelRequest(requestId); + } + } catch (error) { + this.logger.error('[ACP Permission Caller] Failed to cancel request:', error); + } + } + + private buildPermissionContent(request: RequestPermissionRequest): string { + const parts: string[] = []; + + if (request.toolCall.title) { + parts.push(`${request.toolCall.title}`); + } + + if (request.toolCall.locations?.length) { + const files = request.toolCall.locations.map((loc) => loc.path).join(', '); + parts.push(`Affected files: ${files}`); + } + + const command = (request.toolCall.rawInput as Record)?.command; + if (command) { + parts.push(`Command: \`${command}\``); + } + + return parts.join('\n\n'); + } + + private buildPermissionResponse( + decision: AcpPermissionDecision, + options: PermissionOption[], + ): RequestPermissionResponse { + switch (decision.type) { + case 'allow': + case 'reject': { + const optionId = decision.optionId || this.findOptionId(decision.type, options); + return { + outcome: { + outcome: 'selected' as const, + optionId, + }, + }; + } + case 'timeout': + case 'cancelled': + return { + outcome: { + outcome: 'cancelled' as const, + }, + }; + default: + return { + outcome: { + outcome: 'cancelled' as const, + }, + }; + } + } + + private findOptionId(decisionType: 'allow' | 'reject', options: PermissionOption[]): string { + const kinds = decisionType === 'allow' ? ['allow_once', 'allow_always'] : ['reject_once', 'reject_always']; + + for (const kind of kinds) { + const option = options.find((o) => o.kind === kind); + if (option) { + return option.optionId; + } + } + + const prefix = decisionType === 'allow' ? 'allow' : 'reject'; + const anyMatching = options.find((o) => o.kind.startsWith(prefix)); + if (anyMatching) { + return anyMatching.optionId; + } + + return options[0]?.optionId || ''; + } + + /** + * Sort permission options by kind to ensure consistent display order + * Order: allow_always > allow_once > reject_always > reject_once + */ + private sortOptionsByKind(options: PermissionOption[]): PermissionOption[] { + const kindOrder: Record = { + allow_always: 0, + allow_once: 1, + reject_always: 2, + reject_once: 3, + }; + + return [...options].sort((a, b) => { + const orderA = kindOrder[a.kind] ?? Number.MAX_SAFE_INTEGER; + const orderB = kindOrder[b.kind] ?? Number.MAX_SAFE_INTEGER; + return orderA - orderB; + }); + } +} diff --git a/packages/ai-native/src/node/acp/cli-agent-process-manager.ts b/packages/ai-native/src/node/acp/cli-agent-process-manager.ts new file mode 100644 index 0000000000..207fad4020 --- /dev/null +++ b/packages/ai-native/src/node/acp/cli-agent-process-manager.ts @@ -0,0 +1,443 @@ +/** + * CLI Agent 进程管理器 + * + * 以单一实例模式管理 ACP CLI Agent 子进程的完整生命周期: + * - 整个应用只维护一个 Agent 进程实例(singleton) + * - startAgent:若进程已存在且仍在运行则直接复用,否则停止旧进程后重新创建 + * - 提供优雅关闭(SIGTERM)和强制杀进程(SIGKILL)两种停止策略 + * - 暴露 isRunning / getExitCode / listRunningAgents 等状态查询接口 + */ +import { ChildProcess, spawn } from 'child_process'; + +import { Autowired, Injectable } from '@opensumi/di'; +import { INodeLogger } from '@opensumi/ide-core-node'; + +export const CliAgentProcessManagerToken = Symbol('CliAgentProcessManagerToken'); + +/** + * 进程配置常量 + */ +const PROCESS_CONFIG = { + /** 优雅关闭超时时间(毫秒) */ + GRACEFUL_SHUTDOWN_TIMEOUT_MS: 5000, + /** 强制杀死超时时间(毫秒) */ + FORCE_KILL_TIMEOUT_MS: 3000, + /** 启动超时时间(毫秒) */ + STARTUP_TIMEOUT_MS: 100, +} as const; + +/** + * 单一实例模式的 CLI Agent 进程管理器 + * 整个应用生命周期内只维护一个 Agent 进程实例 + */ +export interface ICliAgentProcessManager { + /** + * 启动或返回已有的 Agent 进程 + * 如果进程已存在且仍在运行,直接返回已有进程 + * 如果进程已退出,清理后重新创建 + * 如果调用参数与现有进程不同,会先停止现有进程再创建新的 + */ + startAgent( + command: string, + args: string[], + env: Record, + cwd: string, + ): Promise<{ processId: string; stdout: NodeJS.ReadableStream; stdin: NodeJS.WritableStream }>; + /** + * 停止当前运行的 Agent 进程 + * 单一实例模式下,processId 参数被忽略 + */ + stopAgent(): Promise; + /** + * 强制杀死当前运行的 Agent 进程 + * 单一实例模式下,processId 参数被忽略 + */ + killAgent(): Promise; + /** + * 检查当前进程是否仍在运行 + * 单一实例模式下,processId 参数被忽略 + */ + isRunning(): boolean; + /** + * 获取当前进程的退出码 + * 单一实例模式下,processId 参数被忽略 + */ + getExitCode(): number | null; + /** + * 列出所有运行的 Agent 进程 + * 单一实例模式下,最多返回一个进程 ID + */ + listRunningAgents(): string[]; + /** + * 杀死所有 Agent 进程 + * 单一实例模式下,等同于 killAgent + */ + killAllAgents(): Promise; +} + +/** + * 单一实例模式的 CLI Agent 进程管理器 + * + * 设计原则: + * 1. 整个应用生命周期内只维护一个 Agent 进程实例 + * 2. startAgent 返回已有的进程(如果已存在且仍在运行) + * 3. 如果进程已退出,清理后重新创建 + * 4. 如果调用参数与现有进程不同,先停止现有进程再创建新的 + */ +@Injectable() +export class CliAgentProcessManager implements ICliAgentProcessManager { + // 直接持有 ChildProcess 对象,不需要包装 + private currentProcess: ChildProcess | null = null; + // 单独跟踪 cwd,因为 ChildProcess 没有 cwd 属性 + private currentCwd: string | null = null; + + // 固定进程 ID(单一实例模式使用常量) + private readonly SINGLETON_PROCESS_ID = 'singleton-agent-process'; + + @Autowired(INodeLogger) + private readonly logger: INodeLogger; + + /** + * 判断进程是否在运行(三合一检查) + * 1. process.killed - 是否被标记为杀死 + * 2. process.exitCode !== null - 是否已有退出码 + * 3. process.kill(pid, 0) - 确认进程是否实际存在 + */ + private isProcessRunning(): boolean { + if (!this.currentProcess) { + return false; + } + + // 被标记为 killed 或已有退出码,说明进程已退出 + if (this.currentProcess.killed || this.currentProcess.exitCode !== null) { + return false; + } + + // pid 不存在,说明进程未启动完成 + if (!this.currentProcess.pid) { + return false; + } + + // 使用 process.kill(0) 确认进程是否存在(不发送信号,仅检查)__抛出异常__:进程不存在或没有权限,进入 `catch` 块返回 `false` + try { + process.kill(this.currentProcess.pid, 0); + return true; + } catch { + // 进程不存在 + return false; + } + } + + /** + * 比较配置是否相同(只关心 cwd,因为 cwd 决定了工作目录) + */ + private isConfigSame(command: string, args: string[], env: Record, cwd: string): boolean { + // 简化:只检查 cwd 是否相同 + return cwd === this.currentCwd; + } + + /** + * 启动或返回已有的 Agent 进程 + * + * 行为: + * 1. 如果已有进程且仍在运行,直接返回 + * 2. 如果已有进程但已退出,清理后重新创建 + * 3. 如果调用参数与现有进程不同,先停止现有进程再创建新的 + */ + async startAgent( + command: string, + args: string[], + env: Record, + cwd: string, + ): Promise<{ processId: string; stdout: NodeJS.ReadableStream; stdin: NodeJS.WritableStream }> { + this.logger?.log(`[CliAgentProcessManager] startAgent called: command=${command}, cwd=${cwd}`); + + // 检查是否已有进程且仍在运行 + if (this.currentProcess && this.isProcessRunning()) { + // 检查配置是否相同 + const isConfigSame = this.isConfigSame(command, args, env, cwd); + if (isConfigSame) { + this.logger?.log('[CliAgentProcessManager] Reusing existing running process'); + return { + processId: this.currentProcess.pid!.toString(), + stdout: this.currentProcess.stdio[1] as NodeJS.ReadableStream, + stdin: this.currentProcess.stdio[0] as NodeJS.WritableStream, + }; + } else { + // 配置不同,先停止现有进程 + this.logger?.log('[CliAgentProcessManager] Config changed, stopping existing process'); + await this.stopAgentInternal(); + } + } else if (this.currentProcess) { + // 进程已退出,自动清理(exit 事件应该已经处理了) + this.logger?.log('[CliAgentProcessManager] Previous process exited, cleaning up'); + this.currentProcess = null; + this.currentCwd = null; + } + + // 创建新进程 + this.logger?.log('[CliAgentProcessManager] Creating new agent process'); + const childProcess = await this.createAgentProcess(command, args, env, cwd); + this.currentProcess = childProcess; + this.currentCwd = cwd; + + this.logger?.log(`[CliAgentProcessManager] Agent process started with PID: ${childProcess.pid}`); + + return { + processId: this.currentProcess.pid!.toString(), + stdout: childProcess.stdio[1] as NodeJS.ReadableStream, + stdin: childProcess.stdio[0] as NodeJS.WritableStream, + }; + } + + /** + * 创建新的 Agent 进程 + */ + private async createAgentProcess( + command: string, + args: string[], + env: Record, + cwd: string, + ): Promise { + // 从环境变量读取 Node 路径,默认使用当前进程的 execPath + // 通过设置 SUMI_ACP_NODE_PATH 环境变量,可以指定 ACP Agent 使用特定版本的 Node.js + // 例如:export SUMI_ACP_NODE_PATH=/Users/lujunsheng/.nvm/versions/node/v22.22.0/bin/node + const nodePath = process.env.SUMI_ACP_NODE_PATH || process.execPath; + + // 从 nodePath 推导出 bin 目录,用于设置 PATH + // 例如:/Users/lujunsheng/.nvm/versions/node/v22.22.0/bin + const nodeBinDir = nodePath.substring(0, nodePath.lastIndexOf('/')); + + this.logger?.log(`[CliAgentProcessManager] Using Node.js path: ${nodePath}`); + this.logger?.log(`[CliAgentProcessManager] Using Node bin directory: ${nodeBinDir}`); + this.logger?.log(`[CliAgentProcessManager] Spawning ACP Agent: ${command} ${args.join(' ')}`); + + // 将 node bin 目录添加到 PATH 开头,确保优先使用指定版本的 node 和相关命令 + const newEnv = { + ...env, + NODE: nodePath, + PATH: `${nodeBinDir}:${process.env.PATH || ''}`, + }; + + const childProcess = spawn(command, args, { + cwd, + stdio: ['pipe', 'pipe', 'pipe'], + detached: false, + shell: false, + env: newEnv, + }); + + return new Promise((resolve, reject) => { + let startupError: Error | null = null; + + // Handle startup errors + childProcess.on('error', (err: Error) => { + this.logger?.error(`Failed to start agent process: ${err.message}`); + startupError = err; + reject(this.wrapError(err, command)); + }); + + childProcess.stderr?.on('data', (data: Buffer) => { + const stderr = data.toString('utf8'); + this.logger?.warn('[CliAgentProcessManager] Agent stderr:', stderr); + }); + + childProcess.on('exit', (code: number | null, signal: string | null) => { + this.logger?.log(`[CliAgentProcessManager] Child process exit event: code=${code}, signal=${signal}`); + this.handleProcessExit(code, signal); + }); + + setTimeout(() => { + if (startupError) { + return; + } + + if (childProcess.pid) { + resolve(childProcess); + } else { + reject(new Error(`Failed to get PID for agent process: ${command}`)); + } + }, PROCESS_CONFIG.STARTUP_TIMEOUT_MS); + }); + } + + /** + * 处理进程退出 - 自动清理状态 + */ + private handleProcessExit(code: number | null, signal: string | null): void { + this.logger?.log(`[CliAgentProcessManager] Process exited: code=${code}, signal=${signal}`); + + // 进程退出后自动清空引用 + this.currentProcess = null; + this.currentCwd = null; + } + + /** + * 杀死进程组 + * 尝试用 -pid kill 进程组,失败后 fallback 到单个进程 kill + * @param pid - 进程 ID + * @param signal - 信号类型 + * @returns 是否成功 + */ + private killProcessGroup(pid: number, signal: NodeJS.Signals): boolean { + try { + // 尝试发送信号到进程组 + process.kill(-pid, signal); + this.logger?.log(`[CliAgentProcessManager] Sent ${signal} to process group -${pid}`); + return true; + } catch (err) { + // 如果进程组 kill 失败,尝试直接 kill 单个进程 + this.logger?.log(`[CliAgentProcessManager] Process group kill failed, trying single process kill for ${pid}`); + try { + process.kill(pid, signal); + this.logger?.log(`[CliAgentProcessManager] Sent ${signal} to process ${pid}`); + return true; + } catch (err2) { + this.logger?.warn(`[CliAgentProcessManager] Error sending ${signal}:`, err2); + return false; + } + } + } + + /** + * 停止当前运行的 Agent 进程(内部方法) + */ + private async stopAgentInternal(): Promise { + if (!this.currentProcess) { + return; + } + + this.logger?.log('[CliAgentProcessManager] Stopping agent process gracefully'); + return new Promise((resolve) => { + if (!this.currentProcess) { + resolve(); + return; + } + + // 1. 先发送 SIGTERM,让进程优雅关闭 + const pid = this.currentProcess.pid; + if (pid) { + this.killProcessGroup(pid, 'SIGTERM'); + } + + // 2. 设置超时,超时后强制杀死 + const forceKillTimeout = setTimeout(() => { + if (this.currentProcess && !this.currentProcess.killed) { + this.logger?.warn('[CliAgentProcessManager] Agent did not exit gracefully, forcing kill'); + if (this.currentProcess.pid) { + this.killProcessGroup(this.currentProcess.pid, 'SIGKILL'); + } + } + resolve(); + }, PROCESS_CONFIG.GRACEFUL_SHUTDOWN_TIMEOUT_MS); + + // 3. 监听进程退出,提前 resolve + this.currentProcess.once('exit', () => { + clearTimeout(forceKillTimeout); + resolve(); + }); + }); + } + + /** + * 停止当前运行的 Agent 进程 + */ + async stopAgent(): Promise { + if (!this.currentProcess) { + this.logger?.warn('[CliAgentProcessManager] Cannot stop agent: process not found'); + return; + } + + await this.stopAgentInternal(); + } + + /** + * 强制杀死当前运行的 Agent 进程 + */ + async killAgent(): Promise { + this.logger?.log('[CliAgentProcessManager] Force killing agent process'); + await this.forceKillInternal(); + } + + /** + * 强制杀死进程(内部方法) + * 使用 -pid 杀死整个进程组,确保子进程也被杀死 + */ + private async forceKillInternal(): Promise { + if (!this.currentProcess || !this.currentProcess.pid) { + this.currentProcess = null; + return; + } + + const pid = this.currentProcess.pid; + + // 记录调用堆栈,便于追踪是谁触发了强制杀死 + const stackTrace = new Error('forceKillInternal called').stack; + this.logger?.debug(`[CliAgentProcessManager] forceKillInternal called for PID ${pid}`, stackTrace); + + // 使用负数 PID 杀死整个进程组(包括子进程) + // 注意:需要使用 process.kill(-pid, signal) 而不是 this.currentProcess.kill(signal) + this.killProcessGroup(pid, 'SIGKILL'); + + // 等待进程退出或超时 + return new Promise((resolve) => { + const timeout = setTimeout(() => { + this.logger?.warn(`[CliAgentProcessManager] Force kill timeout for PID ${pid}, clearing reference`); + this.currentProcess = null; + this.currentCwd = null; + resolve(); + }, PROCESS_CONFIG.FORCE_KILL_TIMEOUT_MS); + + // 统一使用 exit 事件监听,超时机制确保引用最终被清理 + this.currentProcess!.once('exit', () => { + clearTimeout(timeout); + this.logger?.log(`[CliAgentProcessManager] Process ${pid} exited, clearing reference`); + this.currentProcess = null; + this.currentCwd = null; + resolve(); + }); + }); + } + + /** + * 检查当前进程是否仍在运行 + */ + isRunning(): boolean { + return this.isProcessRunning(); + } + + /** + * 获取当前进程的退出码 + */ + getExitCode(): number | null { + return this.currentProcess?.exitCode ?? null; + } + + /** + * 列出所有运行的 Agent 进程 + */ + listRunningAgents(): string[] { + if (this.currentProcess && this.isProcessRunning()) { + return [this.SINGLETON_PROCESS_ID]; + } + return []; + } + + /** + * 杀死所有 Agent 进程 + */ + async killAllAgents(): Promise { + this.logger?.log('[CliAgentProcessManager] Killing all agent processes'); + await this.forceKillInternal(); + } + + private wrapError(err: Error, command: string): Error { + if ((err as any).code === 'ENOENT') { + return new Error(`Command not found: ${command}. Please ensure the CLI agent is installed.`); + } + if ((err as any).code === 'EACCES' || (err as any).code === 'EPERM') { + return new Error(`Permission denied when executing: ${command}`); + } + return err; + } +} diff --git a/packages/ai-native/src/node/acp/handlers/agent-request.handler.ts b/packages/ai-native/src/node/acp/handlers/agent-request.handler.ts new file mode 100644 index 0000000000..23a012e97a --- /dev/null +++ b/packages/ai-native/src/node/acp/handlers/agent-request.handler.ts @@ -0,0 +1,362 @@ +/** + * ACP Agent 请求处理器 + * + * 路由并处理 CLI Agent 通过 JSON-RPC 主动发起的请求(Agent → Client): + * - 文件操作:handleReadTextFile / handleWriteTextFile(写入前需用户授权) + * - 终端操作:handleCreateTerminal / handleTerminalOutput / handleWaitForTerminalExit / handleKillTerminal / handleReleaseTerminal(创建前需用户授权) + * - 权限确认:handlePermissionRequest,通过 AcpPermissionCallerManager 在浏览器端弹出对话框 + * + * 设计说明: + * - 在主 Injector 中作为单例创建,与特定 RPC 连接无关 + * - 权限对话框通过 AcpPermissionCallerManager 静态变量路由到当前活跃 Browser Tab + */ +import { Autowired, Injectable } from '@opensumi/di'; +import { + CreateTerminalRequest, + CreateTerminalResponse, + KillTerminalCommandRequest, + KillTerminalCommandResponse, + ReadTextFileRequest, + ReadTextFileResponse, + ReleaseTerminalRequest, + ReleaseTerminalResponse, + RequestPermissionRequest, + RequestPermissionResponse, + TerminalOutputRequest, + TerminalOutputResponse, + WaitForTerminalExitRequest, + WaitForTerminalExitResponse, + WriteTextFileRequest, + WriteTextFileResponse, +} from '@opensumi/ide-core-common/lib/types/ai-native'; +import { INodeLogger } from '@opensumi/ide-core-node'; + +import { AcpPermissionCallerManagerToken } from '../../acp'; +import { AcpPermissionCallerManager } from '../acp-permission-caller.service'; + +import { AcpFileSystemHandler } from './file-system.handler'; +import { AcpTerminalHandler } from './terminal.handler'; + +/** + * ACP Agent Request Handler - 处理来自 CLI Agent 的请求 + * + * ## 设计说明 + * + * ### 为什么在主 Injector 中创建 + * + * `AcpAgentRequestHandler` 处理的是 CLI Agent 发出的请求,这些请求与特定的 RPC 连接无关: + * - CLI Agent 通过 stdio 与 Node 进程通信,不依赖 Browser Tab + * - 请求中不包含 `clientId` 信息,无法路由到特定的 childInjector + * - 因此必须在主 Injector 中作为单例存在,处理所有来自 CLI Agent 的请求 + * + * ### Injector 层级问题 + * + * 由于 `AcpAgentRequestHandler` 在主 Injector 中创建,它通过 `@Autowired` 注入的 + * `AcpPermissionCallerManager` 不是 childInjector 中与 RPC 连接关联的实例。 + * + * 解决方案:`AcpPermissionCallerManager` 使用静态变量 `currentRpcClient` 共享 RPC client, + * 确保权限对话框在用户当前活跃的 Browser Tab 中显示。 + * + * @see {@link /docs/ai-native/architecture/injector-hierarchy.md} 详细设计文档 + */ +@Injectable() +export class AcpAgentRequestHandler { + @Autowired(AcpFileSystemHandler) + private fileSystemHandler: AcpFileSystemHandler; + + @Autowired(AcpTerminalHandler) + private terminalHandler: AcpTerminalHandler; + + @Autowired(AcpPermissionCallerManagerToken) + private permissionCaller: AcpPermissionCallerManager; + + @Autowired(INodeLogger) + private readonly logger: INodeLogger; + + private initialized = false; + + /** + * Initialize the handler and register for agent requests + */ + initialize(): void { + if (this.initialized) { + return; + } + + this.initialized = true; + + // The agent will send requests to us via JSON-RPC + // We handle them by processing through the appropriate handlers + } + + /** + * Handle permission request from agent + * Shows UI dialog in browser via RPC and returns user's decision + * + * 注意:权限对话框会在用户当前活跃的 Browser Tab 中显示 + * (通过 AcpPermissionCallerManager 的静态变量 currentRpcClient 实现) + */ + async handlePermissionRequest(request: RequestPermissionRequest): Promise { + try { + // Call browser-side permission dialog via RPC + const response = await this.permissionCaller.requestPermission(request); + + return response; + } catch (error) { + this.logger.error('[ACP Node][handlePermissionRequest] Error:', error); + // Return cancelled on error + return { + outcome: { outcome: 'cancelled' as const }, + }; + } + } + + /** + * Handle read text file request (requires read permission) + */ + async handleReadTextFile(request: ReadTextFileRequest): Promise { + try { + // File reading doesn't require permission (it's a read operation) + // But we log it for audit purposes + const result = await this.fileSystemHandler.readTextFile({ + sessionId: request.sessionId, + path: request.path, + line: request.line ?? undefined, + limit: request.limit ?? undefined, + }); + + if (result.error) { + this.logger.error(`[ACP] File read error: ${result.error.message}`); + const err = new Error(result.error.message); + (err as any).code = result.error.code; + throw err; + } + + return { + content: result.content || '', + }; + } catch (error) { + this.logger.error(`[ACP] Failed to read file: ${request.path}`, error); + throw error; + } + } + + /** + * Handle write text file request (requires write permission) + */ + async handleWriteTextFile(request: WriteTextFileRequest): Promise { + try { + // For write operations, request permission from user first + const permissionResponse = await this.permissionCaller.requestPermission({ + sessionId: request.sessionId, + toolCall: { + toolCallId: `write-${Date.now()}`, + title: `Write file: ${request.path}`, + kind: 'write' as any, + status: 'pending', + locations: [{ path: request.path }], + rawInput: { path: request.path, contentLength: request.content?.length }, + }, + // 默认 options - 实际项目中应根据后端 ACP Agent 传入的 options 为准 + options: [ + { optionId: 'allow_once', name: 'Allow Once', kind: 'allow_once' }, + { optionId: 'allow_always', name: 'Allow Always', kind: 'allow_always' }, + { optionId: 'reject_once', name: 'Reject Once', kind: 'reject_once' }, + ], + }); + + if ( + permissionResponse.outcome.outcome !== 'selected' || + !permissionResponse.outcome.optionId?.startsWith('allow_') + ) { + this.logger.warn(`[ACP] Write permission denied for: ${request.path}`); + const err = new Error('Write permission denied'); + (err as any).code = -32003; // FORBIDDEN + throw err; + } + + const result = await this.fileSystemHandler.writeTextFile({ + sessionId: request.sessionId, + path: request.path, + content: request.content, + }); + + if (result.error) { + this.logger.error(`[ACP] File write error: ${result.error.message}`); + const err = new Error(result.error.message); + (err as any).code = result.error.code; + throw err; + } + + return {}; + } catch (error) { + this.logger.error(`[ACP] Failed to write file: ${request.path}`, error); + throw error; + } + } + + /** + * Handle create terminal request (requires command execution permission) + */ + async handleCreateTerminal(request: CreateTerminalRequest): Promise { + try { + // For command execution, request permission from user first + const commandStr = [request.command, ...(request.args || [])].join(' '); + const permissionResponse = await this.permissionCaller.requestPermission({ + sessionId: request.sessionId, + toolCall: { + toolCallId: `terminal-${Date.now()}`, + title: `Run command: ${commandStr}`, + kind: 'execute', + status: 'pending', + rawInput: { command: request.command, args: request.args, cwd: request.cwd }, + }, + // 默认 options - 实际项目中应根据后端 ACP Agent 传入的 options 为准 + options: [ + { optionId: 'allow_once', name: 'Allow Once', kind: 'allow_once' }, + { optionId: 'allow_always', name: 'Allow Always', kind: 'allow_always' }, + { optionId: 'reject_once', name: 'Reject Once', kind: 'reject_once' }, + ], + }); + + if ( + permissionResponse.outcome.outcome !== 'selected' || + !permissionResponse.outcome.optionId?.startsWith('allow_') + ) { + this.logger.warn(`[ACP] Command execution permission denied: ${commandStr}`); + const err = new Error('Command execution permission denied'); + (err as any).code = -32003; // FORBIDDEN + throw err; + } + + const result = await this.terminalHandler.createTerminal({ + sessionId: request.sessionId, + command: request.command, + args: request.args, + env: request.env + ? request.env.reduce>((acc, v) => { + acc[v.name] = v.value; + return acc; + }, {}) + : undefined, + cwd: request.cwd ?? undefined, + outputByteLimit: request.outputByteLimit ?? undefined, + }); + + if (result.error) { + this.logger.error(`[ACP] Terminal creation error: ${result.error.message}`); + throw new Error(result.error.message); + } + + return { + terminalId: result.terminalId || '', + }; + } catch (error) { + this.logger.error(`[ACP] Failed to create terminal: ${request.command}`, error); + throw error; + } + } + + /** + * Handle terminal output request + */ + async handleTerminalOutput(request: TerminalOutputRequest): Promise { + try { + const result = await this.terminalHandler.getTerminalOutput({ + sessionId: request.sessionId, + terminalId: request.terminalId, + }); + + if (result.error) { + this.logger.error(`[ACP] Terminal output error: ${result.error.message}`); + throw new Error(result.error.message); + } + + return { + output: result.output || '', + truncated: result.truncated || false, + exitStatus: result.exitStatus != null ? { exitCode: result.exitStatus } : undefined, + }; + } catch (error) { + this.logger.error('[ACP] Failed to get terminal output', error); + throw error; + } + } + + /** + * Handle wait for terminal exit request + */ + async handleWaitForTerminalExit(request: WaitForTerminalExitRequest): Promise { + try { + const result = await this.terminalHandler.waitForTerminalExit({ + sessionId: request.sessionId, + terminalId: request.terminalId, + }); + + if (result.error) { + this.logger.error(`[ACP] Wait for exit error: ${result.error.message}`); + throw new Error(result.error.message); + } + + return { + exitCode: result.exitCode, + signal: result.signal, + }; + } catch (error) { + this.logger.error('[ACP] Failed to wait for terminal exit', error); + throw error; + } + } + + /** + * Handle kill terminal request + */ + async handleKillTerminal(request: KillTerminalCommandRequest): Promise { + try { + const result = await this.terminalHandler.killTerminal({ + sessionId: request.sessionId, + terminalId: request.terminalId, + }); + + if (result.error) { + this.logger.error(`[ACP] Kill terminal error: ${result.error.message}`); + throw new Error(result.error.message); + } + + return {}; + } catch (error) { + this.logger.error('[ACP] Failed to kill terminal', error); + throw error; + } + } + + /** + * Handle release terminal request + */ + async handleReleaseTerminal(request: ReleaseTerminalRequest): Promise { + try { + const result = await this.terminalHandler.releaseTerminal({ + sessionId: request.sessionId, + terminalId: request.terminalId, + }); + + if (result.error) { + this.logger.error(`[ACP] Release terminal error: ${result.error.message}`); + throw new Error(result.error.message); + } + + return {}; + } catch (error) { + this.logger.error('[ACP] Failed to release terminal', error); + throw error; + } + } + + /** + * Clean up all session resources + */ + async disposeSession(sessionId: string): Promise { + // Release all terminals for this session + await this.terminalHandler.releaseSessionTerminals(sessionId); + } +} diff --git a/packages/ai-native/src/node/acp/handlers/constants.ts b/packages/ai-native/src/node/acp/handlers/constants.ts new file mode 100644 index 0000000000..6ecadc2499 --- /dev/null +++ b/packages/ai-native/src/node/acp/handlers/constants.ts @@ -0,0 +1,24 @@ +/** + * ACP Error Codes + * Based on JSON-RPC 2.0 standard errors + ACP-specific errors + */ + +export const ACPErrorCode = { + // JSON-RPC standard errors + PARSE_ERROR: -32700, + INVALID_REQUEST: -32600, + METHOD_NOT_FOUND: -32601, + INVALID_PARAMS: -32602, + INTERNAL_ERROR: -32603, + + // ACP-specific errors + SERVER_ERROR: -32000, + RESOURCE_NOT_FOUND: -32002, + + // ACP application errors + AUTHENTICATION_REQUIRED: 1000, + SESSION_NOT_FOUND: 1001, + FORBIDDEN: 1003, +} as const; + +export type ACPErrorCode = (typeof ACPErrorCode)[keyof typeof ACPErrorCode]; diff --git a/packages/ai-native/src/node/acp/handlers/file-system.handler.ts b/packages/ai-native/src/node/acp/handlers/file-system.handler.ts new file mode 100644 index 0000000000..b0ada6a6cf --- /dev/null +++ b/packages/ai-native/src/node/acp/handlers/file-system.handler.ts @@ -0,0 +1,477 @@ +/** + * ACP 文件系统操作处理器 + * + * 为 CLI Agent 提供受工作区沙箱限制的文件操作能力: + * - readTextFile:读取文本文件内容,支持按行范围截取 + * - writeTextFile:写入文本文件,写入前可通过 permissionCallback 触发用户授权 + * - getFileMeta:获取文件元信息(大小、修改时间、MIME 类型等) + * - listDirectory:列举目录条目,支持一层递归 + * - createDirectory:创建目录(含父目录) + * + * 安全机制:所有路径均经过 resolvePath 校验,拒绝工作区外的绝对路径和路径穿越攻击。 + */ +import * as fs from 'fs'; +import * as path from 'path'; + +import { Autowired, Injectable } from '@opensumi/di'; +import { ILogger, URI } from '@opensumi/ide-core-common'; +import { IFileService } from '@opensumi/ide-file-service'; + +import { ACPErrorCode } from './constants'; + +export interface FileSystemRequest { + sessionId: string; + path: string; + line?: number; + limit?: number; + content?: string; + recursive?: boolean; +} + +export interface FileSystemResponse { + error?: { + code: number; + message: string; + data?: unknown; + }; + content?: string; + size?: number; + mtime?: number; + isFile?: boolean; + mimeType?: string; + entries?: Array<{ + name: string; + isFile: boolean; + size: number; + }>; +} + +export type PermissionCallback = ( + sessionId: string, + operation: 'write' | 'command', + details: { + path?: string; + command?: string; + title: string; + kind: string; + locations?: Array<{ path: string; line?: number }>; + content?: string; + }, +) => Promise; + +@Injectable() +export class AcpFileSystemHandler { + @Autowired(IFileService) + private fileService: IFileService; + + private logger: ILogger | null = null; + private workspaceDir: string = ''; + private maxFileSize = 1024 * 1024; // 1MB default + private permissionCallback: PermissionCallback | null = null; + + setLogger(logger: ILogger): void { + this.logger = logger; + } + + /** + * Set the permission callback for write operations + */ + setPermissionCallback(callback: PermissionCallback): void { + this.permissionCallback = callback; + } + + configure(options: { workspaceDir: string; maxFileSize?: number }): void { + this.workspaceDir = options.workspaceDir; + if (options.maxFileSize !== undefined) { + this.maxFileSize = options.maxFileSize; + } + } + + async readTextFile(request: FileSystemRequest): Promise { + const filePath = this.resolvePath(request.path); + if (!filePath) { + return { + error: { + code: ACPErrorCode.SERVER_ERROR, + message: 'Invalid path', + data: { path: request.path }, + }, + }; + } + + try { + const uri = URI.file(filePath); + + // Check if file exists + const stat = await this.fileService.getFileStat(uri.toString()); + if (!stat) { + return { + error: { + code: ACPErrorCode.RESOURCE_NOT_FOUND, + message: 'File not found', + data: { uri: uri.toString() }, + }, + }; + } + + // Check file size + if (stat.size && stat?.size > this.maxFileSize) { + return { + error: { + code: ACPErrorCode.SERVER_ERROR, + message: `File too large: ${stat.size} bytes (max: ${this.maxFileSize})`, + data: { path: request.path, size: stat.size }, + }, + }; + } + + // Read file content + const content = (await this.fileService.resolveContent(uri.toString())).content; + let text = content.toString(); + + // Apply line range if specified + if (request.line !== undefined || request.limit !== undefined) { + const lines = text.split('\n'); + const startLine = (request.line ?? 1) - 1; + const limit = request.limit ?? lines.length; + text = lines.slice(startLine, startLine + limit).join('\n'); + } + + return { + content: text, + }; + } catch (error) { + this.logger?.error(`Error reading file ${filePath}:`, error); + return { + error: { + code: ACPErrorCode.SERVER_ERROR, + message: error instanceof Error ? error.message : 'Failed to read file', + data: { path: request.path }, + }, + }; + } + } + + async writeTextFile(request: FileSystemRequest): Promise { + const filePath = this.resolvePath(request.path); + if (!filePath) { + return { + error: { + code: ACPErrorCode.SERVER_ERROR, + message: 'Invalid path', + data: { path: request.path }, + }, + }; + } + + if (request.content === undefined) { + return { + error: { + code: ACPErrorCode.INVALID_PARAMS, + message: 'Content is required', + }, + }; + } + + // Check permission for write operation if callback is set + if (this.permissionCallback) { + const permitted = await this.permissionCallback(request.sessionId, 'write', { + path: filePath, + title: `Write file: ${path.basename(filePath)}`, + kind: 'write', + locations: [{ path: filePath }], + content: request.content.substring(0, 200), // Include preview + }); + + if (!permitted) { + this.logger?.warn(`Write permission denied for: ${filePath}`); + return { + error: { + code: ACPErrorCode.FORBIDDEN, + message: 'Write permission denied', + data: { path: filePath }, + }, + }; + } + } + + try { + const uri = URI.file(filePath); + + // Create parent directories if needed + const parentUri = uri.parent; + const parentStat = await this.fileService.getFileStat(parentUri.toString()); + if (!parentStat) { + await this.fileService.createFolder(parentUri.toString()); + } + + // Write file content + const buffer = Buffer.from(request.content, 'utf8'); + const filestat = await this.fileService.getFileStat(uri.toString()); + if (filestat) { + await this.fileService.setContent(filestat, buffer.toString()); + } else { + await this.fileService.createFile(uri.toString(), { content: buffer.toString() }); + } + + this.logger?.log(`File written: ${filePath}`); + + return {}; + } catch (error) { + this.logger?.error(`Error writing file ${filePath}:`, error); + return { + error: { + code: ACPErrorCode.SERVER_ERROR, + message: error instanceof Error ? error.message : 'Failed to write file', + data: { path: request.path }, + }, + }; + } + } + + async getFileMeta(request: FileSystemRequest): Promise { + const filePath = this.resolvePath(request.path); + if (!filePath) { + return { + error: { + code: ACPErrorCode.SERVER_ERROR, + message: 'Invalid path', + data: { path: request.path }, + }, + }; + } + + try { + const uri = URI.file(filePath); + const stat = await this.fileService.getFileStat(uri.toString()); + + if (!stat) { + // File doesn't exist, return false for existence check + return { + isFile: false, + size: 0, + mtime: 0, + }; + } + + return { + size: stat.size, + mtime: stat.lastModification, + isFile: !stat.isDirectory, + mimeType: this.detectMimeType(filePath), + }; + } catch (error) { + this.logger?.error(`Error getting file meta ${filePath}:`, error); + return { + error: { + code: ACPErrorCode.SERVER_ERROR, + message: error instanceof Error ? error.message : 'Failed to get file metadata', + data: { path: request.path }, + }, + }; + } + } + + async listDirectory(request: FileSystemRequest): Promise { + const dirPath = this.resolvePath(request.path); + if (!dirPath) { + return { + error: { + code: ACPErrorCode.SERVER_ERROR, + message: 'Invalid path', + data: { path: request.path }, + }, + }; + } + + try { + const uri = URI.file(dirPath); + const stat = await this.fileService.getFileStat(uri.toString()); + + if (!stat) { + return { + error: { + code: ACPErrorCode.RESOURCE_NOT_FOUND, + message: 'Directory not found', + data: { path: request.path }, + }, + }; + } + + if (!stat.isDirectory) { + return { + error: { + code: ACPErrorCode.INVALID_PARAMS, + message: 'Path is a file, not a directory', + data: { path: request.path }, + }, + }; + } + + const entries: Array<{ name: string; isFile: boolean; size: number }> = []; + + if (stat.children) { + for (const child of stat.children) { + entries.push({ + name: path.basename(child.uri.toString()), + isFile: !child.isDirectory, + size: child.size || 0, + }); + const childName = path.basename(child.uri.toString()); + // Handle recursive listing + if (request.recursive && child.isDirectory && child.children) { + for (const grandChild of child.children) { + entries.push({ + name: `${childName}/${path.basename(grandChild.uri.toString())}`, + isFile: !grandChild.isDirectory, + size: grandChild.size || 0, + }); + } + } + } + } + + return { + entries, + }; + } catch (error) { + this.logger?.error(`Error listing directory ${dirPath}:`, error); + return { + error: { + code: ACPErrorCode.SERVER_ERROR, + message: error instanceof Error ? error.message : 'Failed to list directory', + data: { path: request.path }, + }, + }; + } + } + + async createDirectory(request: FileSystemRequest): Promise { + const dirPath = this.resolvePath(request.path); + if (!dirPath) { + return { + error: { + code: ACPErrorCode.SERVER_ERROR, + message: 'Invalid path', + data: { path: request.path }, + }, + }; + } + + // Check permission for write operation if callback is set + if (this.permissionCallback) { + const permitted = await this.permissionCallback(request.sessionId, 'write', { + path: dirPath, + title: `Create directory: ${path.basename(dirPath)}`, + kind: 'createDirectory', + locations: [{ path: dirPath }], + }); + + if (!permitted) { + this.logger?.warn(`Create directory permission denied for: ${dirPath}`); + return { + error: { + code: ACPErrorCode.FORBIDDEN, + message: 'Create directory permission denied', + data: { path: dirPath }, + }, + }; + } + } + + try { + const uri = URI.file(dirPath); + await this.fileService.createFolder(uri.toString()); + + this.logger?.log(`Directory created: ${dirPath}`); + + return {}; + } catch (error) { + this.logger?.error(`Error creating directory ${dirPath}:`, error); + return { + error: { + code: ACPErrorCode.SERVER_ERROR, + message: error instanceof Error ? error.message : 'Failed to create directory', + data: { path: request.path }, + }, + }; + } + } + + /** + * Resolve a path relative to workspace, validating it stays within workspace bounds + */ + private resolvePath(inputPath: string): string | null { + // Reject immediately if workspaceDir is not set + if (!this.workspaceDir) { + this.logger?.warn('Workspace directory not configured'); + return null; + } + + // Resolve the input path (handles both absolute and relative paths) + let resolvedPath: string; + if (path.isAbsolute(inputPath)) { + resolvedPath = path.resolve(inputPath); + } else { + resolvedPath = path.resolve(this.workspaceDir, inputPath); + } + + // Resolve symlinks for both the resolved path and workspace directory + let realResolvedPath: string; + let realWorkspaceDir: string; + try { + realResolvedPath = fs.realpathSync(resolvedPath); + } catch (error) { + // If the path doesn't exist yet (e.g., new file for write), use the resolved path as-is + realResolvedPath = resolvedPath; + } + try { + realWorkspaceDir = fs.realpathSync(this.workspaceDir); + } catch (error) { + this.logger?.warn(`Cannot resolve workspace directory: ${this.workspaceDir}`); + return null; + } + + // Compute the relative path and ensure it does not escape workspace + const relativePath = path.relative(realWorkspaceDir, realResolvedPath); + + // Reject if relative path equals '..' or starts with '..' + separator + if (relativePath === '..' || relativePath.startsWith(`..${path.sep}`)) { + this.logger?.warn(`Path outside workspace rejected: ${inputPath}`); + return null; + } + + return realResolvedPath; + } + + /** + * Detect MIME type based on file extension + */ + private detectMimeType(filePath: string): string { + const ext = path.extname(filePath).toLowerCase(); + const mimeTypes: Record = { + '.txt': 'text/plain', + '.md': 'text/markdown', + '.js': 'application/javascript', + '.ts': 'application/typescript', + '.jsx': 'text/jsx', + '.tsx': 'text/tsx', + '.json': 'application/json', + '.css': 'text/css', + '.html': 'text/html', + '.xml': 'application/xml', + '.yaml': 'application/yaml', + '.yml': 'application/yaml', + '.py': 'text/x-python', + '.java': 'text/x-java', + '.go': 'text/x-go', + '.rs': 'text/x-rust', + '.c': 'text/x-c', + '.cpp': 'text/x-c++', + '.h': 'text/x-c', + '.hpp': 'text/x-c++', + }; + + return mimeTypes[ext] || 'application/octet-stream'; + } +} diff --git a/packages/ai-native/src/node/acp/handlers/terminal.handler.ts b/packages/ai-native/src/node/acp/handlers/terminal.handler.ts new file mode 100644 index 0000000000..a5e2e830d7 --- /dev/null +++ b/packages/ai-native/src/node/acp/handlers/terminal.handler.ts @@ -0,0 +1,469 @@ +/** + * ACP 终端操作处理器 + * + * 为 CLI Agent 提供进程级终端(命令执行)能力: + * - createTerminal:创建新终端并执行命令,创建前可通过 permissionCallback 触发用户授权; + * 自动收集输出并按 outputByteLimit 滑动截断 + * - getTerminalOutput:读取终端当前输出缓冲及退出状态 + * - waitForTerminalExit:等待终端进程退出(带超时) + * - killTerminal:强制终止终端进程 + * - releaseTerminal / releaseSessionTerminals:释放终端资源,支持按 Session 批量释放 + */ +import * as pty from 'node-pty'; + +import { Autowired, Injectable } from '@opensumi/di'; +import { uuid } from '@opensumi/ide-core-common'; +import { INodeLogger } from '@opensumi/ide-core-node'; + +import { ACPErrorCode } from './constants'; + +// Re-export the permission callback type for convenience +export type TerminalPermissionCallback = ( + sessionId: string, + operation: 'command', + details: { + command: string; + args?: string[]; + cwd?: string; + title: string; + kind: string; + }, +) => Promise; + +export interface TerminalRequest { + sessionId: string; + command?: string; + args?: string[]; + env?: Record; + cwd?: string; + outputByteLimit?: number; + terminalId?: string; + timeout?: number; +} + +export interface TerminalResponse { + error?: { + code: number; + message: string; + }; + terminalId?: string; + output?: string; + truncated?: boolean; + exitStatus?: number | null; + exitCode?: number; + signal?: string; +} + +interface TerminalSession { + terminalId: string; + sessionId: string; + ptyProcess: pty.IPty; + outputBuffer: string; + outputByteLimit: number; + exited: boolean; + exitCode?: number; + killed: boolean; + startTime: number; +} + +@Injectable() +export class AcpTerminalHandler { + @Autowired(INodeLogger) + private readonly logger: INodeLogger; + + private terminals = new Map(); + private defaultOutputLimit = 1024 * 1024; // 1MB default + private permissionCallback: TerminalPermissionCallback | null = null; + + /** + * Set the permission callback for terminal command execution + */ + setPermissionCallback(callback: TerminalPermissionCallback): void { + this.permissionCallback = callback; + } + + configure(options: { outputLimit?: number }): void { + if (options.outputLimit !== undefined) { + this.defaultOutputLimit = options.outputLimit; + } + } + + async createTerminal(request: TerminalRequest): Promise { + const startTime = Date.now(); + this.logger?.log( + `[AcpTerminalHandler] createTerminal called, sessionId=${request.sessionId}, command=${ + request.command + }, args=${JSON.stringify(request.args)}`, + ); + + try { + const terminalId = uuid(); + this.logger?.log(`[AcpTerminalHandler] Generated terminalId: ${terminalId}`); + + // Check permission for command execution if callback is set + if (this.permissionCallback) { + const commandStr = [request.command, ...(request.args || [])].join(' '); + this.logger?.log(`[AcpTerminalHandler] Checking permission for command: ${commandStr}`); + + const permitted = await this.permissionCallback(request.sessionId, 'command', { + command: commandStr, + args: request.args, + cwd: request.cwd, + title: `Run command: ${commandStr}`, + kind: 'command', + }); + + if (!permitted) { + this.logger?.warn(`[AcpTerminalHandler] Command execution permission denied: ${commandStr}`); + return { + error: { + code: ACPErrorCode.FORBIDDEN, + message: 'Command execution permission denied', + }, + }; + } + this.logger?.log(`[AcpTerminalHandler] Permission granted for command: ${commandStr}`); + } + + // Merge environment variables + const env = { + ...process.env, + ...request.env, + }; + this.logger?.log( + `[AcpTerminalHandler] Spawning PTY process: command=${request.command || '/bin/sh'}, cwd=${ + request.cwd || process.cwd() + }`, + ); + + // Create PTY process using node-pty + const ptyProcess = pty.spawn(request.command || '/bin/sh', request.args || [], { + name: 'xterm-256color', + cwd: request.cwd || process.cwd(), + env, + cols: 80, + rows: 24, + }); + + this.logger?.log(`[AcpTerminalHandler] PTY process spawned successfully, pid=${ptyProcess.pid}`); + + const terminalSession: TerminalSession = { + terminalId, + sessionId: request.sessionId, + ptyProcess, + outputBuffer: '', + outputByteLimit: request.outputByteLimit ?? this.defaultOutputLimit, + exited: false, + killed: false, + startTime: Date.now(), + }; + + // Listen to terminal output + ptyProcess.onData((data) => { + if (!terminalSession.killed) { + terminalSession.outputBuffer += data; + + // Trim buffer if it exceeds limit + const bufferSize = Buffer.byteLength(terminalSession.outputBuffer, 'utf8'); + if (bufferSize > terminalSession.outputByteLimit) { + // Keep recent output, drop old data + const keepSize = Math.floor(terminalSession.outputByteLimit * 0.8); + terminalSession.outputBuffer = terminalSession.outputBuffer.slice(-keepSize); + this.logger?.debug(`[AcpTerminalHandler] Terminal output buffer trimmed, kept ${keepSize} bytes`); + } + } + }); + + // Listen to exit + ptyProcess.onExit((e) => { + terminalSession.exited = true; + terminalSession.exitCode = e.exitCode; + const duration = Date.now() - startTime; + this.logger?.log( + `[AcpTerminalHandler] Terminal ${terminalId} exited with code ${e.exitCode}, duration=${duration}ms`, + ); + }); + + this.terminals.set(terminalId, terminalSession); + this.logger?.log( + `[AcpTerminalHandler] Terminal created successfully: ${terminalId}, total terminals: ${this.terminals.size}`, + ); + + return { + terminalId, + }; + } catch (error) { + this.logger?.error('[AcpTerminalHandler] Error creating terminal:', error); + return { + error: { + code: ACPErrorCode.SERVER_ERROR, + message: error instanceof Error ? error.message : 'Failed to create terminal', + }, + }; + } + } + + async getTerminalOutput(request: TerminalRequest): Promise { + this.logger?.debug(`[AcpTerminalHandler] getTerminalOutput called, terminalId=${request.terminalId}`); + + const terminalSession = this.terminals.get(request.terminalId || ''); + if (!terminalSession) { + this.logger?.warn(`[AcpTerminalHandler] Terminal not found: ${request.terminalId}`); + return { + error: { + code: ACPErrorCode.RESOURCE_NOT_FOUND, + message: 'Terminal not found', + }, + }; + } + + if (terminalSession.sessionId !== request.sessionId) { + this.logger?.warn( + `[AcpTerminalHandler] Session mismatch: expected ${terminalSession.sessionId}, got ${request.sessionId}`, + ); + return { + error: { + code: ACPErrorCode.SERVER_ERROR, + message: 'Session mismatch', + }, + }; + } + + const output = terminalSession.outputBuffer; + const bufferSize = Buffer.byteLength(output, 'utf8'); + const truncated = bufferSize > terminalSession.outputByteLimit; + + this.logger?.debug( + `[AcpTerminalHandler] getTerminalOutput: bufferSize=${bufferSize}, truncated=${truncated}, exited=${terminalSession.exited}`, + ); + + return { + output, + truncated, + exitStatus: terminalSession.exited ? terminalSession.exitCode ?? 0 : null, + }; + } + + async waitForTerminalExit(request: TerminalRequest): Promise { + this.logger?.debug( + `[AcpTerminalHandler] waitForTerminalExit called, terminalId=${request.terminalId}, timeout=${ + request.timeout ?? 30000 + }ms`, + ); + + const terminalSession = this.terminals.get(request.terminalId || ''); + if (!terminalSession) { + this.logger?.warn(`[AcpTerminalHandler] Terminal not found: ${request.terminalId}`); + return { + error: { + code: ACPErrorCode.RESOURCE_NOT_FOUND, + message: 'Terminal not found', + }, + }; + } + + if (terminalSession.sessionId !== request.sessionId) { + this.logger?.warn( + `[AcpTerminalHandler] Session mismatch: expected ${terminalSession.sessionId}, got ${request.sessionId}`, + ); + return { + error: { + code: ACPErrorCode.SERVER_ERROR, + message: 'Session mismatch', + }, + }; + } + + // If already exited, return immediately + if (terminalSession.exited) { + this.logger?.log( + `[AcpTerminalHandler] Terminal ${request.terminalId} already exited, code=${terminalSession.exitCode}`, + ); + return { + exitCode: terminalSession.exitCode, + }; + } + + this.logger?.log(`[AcpTerminalHandler] Waiting for terminal ${request.terminalId} to exit...`); + + // Wait for exit with timeout + const timeout = request.timeout ?? 30000; // 30s default + const waitStartTime = Date.now(); + + return new Promise((resolve) => { + const checkInterval = setInterval(() => { + if (terminalSession.exited) { + clearInterval(checkInterval); + clearTimeout(timeoutId); + const waitDuration = Date.now() - waitStartTime; + this.logger?.log( + `[AcpTerminalHandler] Terminal ${request.terminalId} exited after ${waitDuration}ms, code=${terminalSession.exitCode}`, + ); + resolve({ + exitCode: terminalSession.exitCode, + }); + } + }, 100); + + const timeoutId = setTimeout(() => { + clearInterval(checkInterval); + const waitDuration = Date.now() - waitStartTime; + this.logger?.warn( + `[AcpTerminalHandler] waitForTerminalExit timeout after ${waitDuration}ms for terminal ${request.terminalId}`, + ); + // Return null exitStatus to indicate still running + resolve({ + exitStatus: null, + }); + }, timeout); + }); + } + + async killTerminal(request: TerminalRequest): Promise { + const terminalSession = this.terminals.get(request.terminalId || ''); + if (!terminalSession) { + return { + error: { + code: ACPErrorCode.RESOURCE_NOT_FOUND, + message: 'Terminal not found', + }, + }; + } + + if (terminalSession.sessionId !== request.sessionId) { + return { + error: { + code: ACPErrorCode.SERVER_ERROR, + message: 'Session mismatch', + }, + }; + } + + // If already exited, just return success + if (terminalSession.exited) { + return { + exitStatus: terminalSession.exitCode ?? 0, + }; + } + + try { + this.logger?.log(`Killing terminal ${request.terminalId}`); + + terminalSession.killed = true; + + // Kill the PTY process + terminalSession.ptyProcess.kill(); + + // Wait for graceful exit + await new Promise((resolve) => { + const checkInterval = setInterval(() => { + if (terminalSession.exited) { + clearInterval(checkInterval); + resolve(); + } + }, 100); + + // Force kill after 2 seconds + setTimeout(() => { + clearInterval(checkInterval); + resolve(); + }, 2000); + }); + + // If not exited, mark as exited + if (!terminalSession.exited) { + terminalSession.exited = true; + } + + return { + exitCode: terminalSession.exitCode ?? -1, + }; + } catch (error) { + this.logger?.error('Error killing terminal:', error); + return { + error: { + code: ACPErrorCode.SERVER_ERROR, + message: error instanceof Error ? error.message : 'Failed to kill terminal', + }, + }; + } + } + + async releaseTerminal(request: TerminalRequest): Promise { + const terminalSession = this.terminals.get(request.terminalId || ''); + if (!terminalSession) { + // Already released or doesn't exist + return {}; + } + + if (terminalSession.sessionId !== request.sessionId) { + return { + error: { + code: ACPErrorCode.SERVER_ERROR, + message: 'Session mismatch', + }, + }; + } + + try { + this.logger?.log(`Releasing terminal ${request.terminalId}`); + + // Kill the PTY process if not already exited + if (!terminalSession.exited) { + try { + terminalSession.ptyProcess.kill(); + } catch (e) { + this.logger?.warn(`Failed to kill pty process ${request.terminalId}:`, e); + } + } + + // Remove from tracking + this.terminals.delete(request.terminalId || ''); + + return {}; + } catch (error) { + this.logger?.error('Error releasing terminal:', error); + return { + error: { + code: ACPErrorCode.SERVER_ERROR, + message: error instanceof Error ? error.message : 'Failed to release terminal', + }, + }; + } + } + + /** + * Release all terminals for a session + */ + async releaseSessionTerminals(sessionId: string): Promise { + const terminalsToRelease: string[] = []; + + for (const [terminalId, session] of this.terminals) { + if (session.sessionId === sessionId) { + terminalsToRelease.push(terminalId); + } + } + + for (const terminalId of terminalsToRelease) { + await this.releaseTerminal({ + sessionId, + terminalId, + }); + } + + this.logger?.log(`Released ${terminalsToRelease.length} terminals for session ${sessionId}`); + } + + /** + * Get all terminal IDs for a session + */ + getSessionTerminals(sessionId: string): string[] { + const terminalIds: string[] = []; + for (const [terminalId, session] of this.terminals) { + if (session.sessionId === sessionId) { + terminalIds.push(terminalId); + } + } + return terminalIds; + } +} diff --git a/packages/ai-native/src/node/acp/index.ts b/packages/ai-native/src/node/acp/index.ts new file mode 100644 index 0000000000..0ca10b9aac --- /dev/null +++ b/packages/ai-native/src/node/acp/index.ts @@ -0,0 +1,12 @@ +export { AcpCliClientService } from './acp-cli-client.service'; +export { + CliAgentProcessManager, + CliAgentProcessManagerToken, + ICliAgentProcessManager, +} from './cli-agent-process-manager'; +export { AcpCliBackService, AcpCliBackServiceToken } from './acp-cli-back.service'; +export { AcpFileSystemHandler } from './handlers/file-system.handler'; +export { AcpTerminalHandler } from './handlers/terminal.handler'; +export { AcpAgentRequestHandler } from './handlers/agent-request.handler'; +export { AcpAgentService, AcpAgentServiceToken, IAcpAgentService } from './acp-agent.service'; +export { AcpPermissionCallerManager, AcpPermissionCallerManagerToken } from './acp-permission-caller.service'; diff --git a/packages/ai-native/src/node/index.ts b/packages/ai-native/src/node/index.ts index f455b6a92d..20a0a6efe4 100644 --- a/packages/ai-native/src/node/index.ts +++ b/packages/ai-native/src/node/index.ts @@ -1,11 +1,28 @@ import { Injectable, Provider } from '@opensumi/di'; -import { AIBackSerivcePath, AIBackSerivceToken } from '@opensumi/ide-core-common'; +import { + AIBackSerivcePath, + AIBackSerivceToken, + AcpCliClientServiceToken, + AcpPermissionServicePath, +} from '@opensumi/ide-core-common'; import { NodeModule } from '@opensumi/ide-core-node'; -import { BaseAIBackService } from '@opensumi/ide-core-node/lib/ai-native/base-back.service'; import { SumiMCPServerProxyServicePath, TokenMCPServerProxyService } from '../common'; import { ToolInvocationRegistryManager, ToolInvocationRegistryManagerImpl } from '../common/tool-invocation-registry'; +import { + AcpAgentRequestHandler, + AcpAgentService, + AcpAgentServiceToken, + AcpFileSystemHandler, + AcpPermissionCallerManager, + AcpPermissionCallerManagerToken, + AcpTerminalHandler, + CliAgentProcessManager, + CliAgentProcessManagerToken, +} from './acp'; +import { AcpCliBackService } from './acp/acp-cli-back.service'; +import { AcpCliClientService } from './acp/acp-cli-client.service'; import { SumiMCPServerBackend } from './mcp/sumi-mcp-server'; @Injectable() @@ -13,7 +30,23 @@ export class AINativeModule extends NodeModule { providers: Provider[] = [ { token: AIBackSerivceToken, - useClass: BaseAIBackService, + useClass: AcpCliBackService, + }, + { + token: AcpCliClientServiceToken, + useClass: AcpCliClientService, + }, + { + token: CliAgentProcessManagerToken, + useClass: CliAgentProcessManager, + }, + { + token: AcpAgentServiceToken, + useClass: AcpAgentService, + }, + { + token: AcpPermissionCallerManagerToken, + useClass: AcpPermissionCallerManager, }, { token: ToolInvocationRegistryManager, @@ -23,6 +56,9 @@ export class AINativeModule extends NodeModule { token: TokenMCPServerProxyService, useClass: SumiMCPServerBackend, }, + AcpFileSystemHandler, + AcpTerminalHandler, + AcpAgentRequestHandler, ]; backServices = [ @@ -38,5 +74,9 @@ export class AINativeModule extends NodeModule { servicePath: SumiMCPServerProxyServicePath, token: TokenMCPServerProxyService, }, + { + servicePath: AcpPermissionServicePath, + token: AcpPermissionCallerManagerToken, + }, ]; } diff --git a/packages/core-browser/src/ai-native/ai-config.service.ts b/packages/core-browser/src/ai-native/ai-config.service.ts index a2b422efe9..0cbf4f4c67 100644 --- a/packages/core-browser/src/ai-native/ai-config.service.ts +++ b/packages/core-browser/src/ai-native/ai-config.service.ts @@ -23,6 +23,7 @@ const DEFAULT_CAPABILITIES: Required = { supportsTerminalCommandSuggest: true, supportsCustomLLMSettings: true, supportsMCP: true, + supportsAgentMode: true, // agent 模式 }; const DISABLED_ALL_CAPABILITIES = {} as Required; diff --git a/packages/core-common/package.json b/packages/core-common/package.json index 7749ea7a4e..53aeef76b7 100644 --- a/packages/core-common/package.json +++ b/packages/core-common/package.json @@ -18,10 +18,12 @@ "build": "tsc --build ../../configs/ts/references/tsconfig.core-common.json" }, "dependencies": { + "@agentclientprotocol/sdk": "^0.16.1", "@opensumi/di": "^1.8.0", "@opensumi/events": "^1.0.0", "@opensumi/ide-utils": "workspace:*", - "ai": "^4.3.16" + "ai": "^4.3.16", + "electron": "^22.3.21" }, "devDependencies": { "@opensumi/ide-dev-tool": "workspace:*" diff --git a/packages/core-common/src/log.ts b/packages/core-common/src/log.ts index d4c9698a80..832a953235 100644 --- a/packages/core-common/src/log.ts +++ b/packages/core-common/src/log.ts @@ -296,7 +296,7 @@ export class DebugLog implements IDebugLog { return console.info(this.getPre('log', 'green'), ...args); }; - destroy() { } + destroy() {} } /** @@ -338,6 +338,6 @@ export function getDebugLogger(namespace?: string): IDebugLog { showWarn(); return debugLog.warn; }, - destroy() { }, + destroy() {}, }; } diff --git a/packages/core-common/src/storage.ts b/packages/core-common/src/storage.ts index 545147a890..633986afd3 100644 --- a/packages/core-common/src/storage.ts +++ b/packages/core-common/src/storage.ts @@ -55,6 +55,7 @@ export const STORAGE_NAMESPACE = { OUTLINE: new URI('outline').withScheme(STORAGE_SCHEMA.SCOPE), CHAT: new URI('chat').withScheme(STORAGE_SCHEMA.SCOPE), MCP: new URI('mcp').withScheme(STORAGE_SCHEMA.SCOPE), + AI_NATIVE: new URI('ai-native').withScheme(STORAGE_SCHEMA.SCOPE), // global database GLOBAL_LAYOUT: new URI('layout-global').withScheme(STORAGE_SCHEMA.GLOBAL), GLOBAL_EXTENSIONS: new URI('extensions').withScheme(STORAGE_SCHEMA.GLOBAL), diff --git a/packages/core-common/src/types/ai-native/acp-types.ts b/packages/core-common/src/types/ai-native/acp-types.ts new file mode 100644 index 0000000000..14af8fe091 --- /dev/null +++ b/packages/core-common/src/types/ai-native/acp-types.ts @@ -0,0 +1,243 @@ +// @ts-nocheck +import type { + AgentCapabilities, + AuthMethod, + AuthenticateRequest, + AuthenticateResponse, + CancelNotification, + Implementation, + InitializeRequest, + InitializeResponse, + ListSessionsRequest, + ListSessionsResponse, + LoadSessionRequest, + LoadSessionResponse, + NewSessionRequest, + NewSessionResponse, + PermissionOption, + PromptRequest, + PromptResponse, + RequestPermissionRequest, + RequestPermissionResponse, + SessionModeState, + SessionNotification, + SetSessionModeRequest, + SetSessionModeResponse, +} from '@agentclientprotocol/sdk'; +/** + * CJS-compatible re-export bridge for @agentclientprotocol/sdk types. + * + * The @agentclientprotocol/sdk package declares "type": "module" in its package.json, + * which causes TS1479 errors in CJS modules when using `nodenext` module resolution. + * Since all imports here are type-only (zero runtime impact), we use @ts-nocheck + * to suppress the diagnostic. All other files import from this bridge instead + * of directly from the SDK. + */ +export type { + AgentCapabilities, + AuthenticateRequest, + AuthenticateResponse, + AuthMethod, + CancelNotification, + ClientCapabilities, + ContentBlock, + CreateTerminalRequest, + CreateTerminalResponse, + Implementation, + InitializeRequest, + InitializeResponse, + ListSessionsRequest, + ListSessionsResponse, + LoadSessionRequest, + LoadSessionResponse, + McpCapabilities, + NewSessionRequest, + NewSessionResponse, + PermissionOption, + PermissionOptionKind, + PromptCapabilities, + PromptRequest, + PromptResponse, + ReadTextFileRequest, + ReadTextFileResponse, + ReleaseTerminalRequest, + ReleaseTerminalResponse, + RequestPermissionRequest, + RequestPermissionResponse, + SessionCapabilities, + SessionInfo, + SessionMode, + SessionModeState, + SessionNotification, + SetSessionModeRequest, + SetSessionModeResponse, + TerminalOutputRequest, + TerminalOutputResponse, + ToolCallLocation, + ToolCallUpdate, + WaitForTerminalExitRequest, + WaitForTerminalExitResponse, + WriteTextFileRequest, + WriteTextFileResponse, + KillTerminalCommandResponse, + KillTerminalCommandRequest, + ToolKind, +} from '@agentclientprotocol/sdk'; + +// Extend InitializeResponse to include modes field (not in official SDK yet) +export type ExtendedInitializeResponse = InitializeResponse & { + modes?: SessionModeState; +}; + +// Permission RPC Service Types +export interface AcpPermissionDialogParams { + requestId: string; + sessionId: string; + title: string; + kind?: string; + content: string; + locations?: Array<{ path: string; line?: number }>; + command?: string; + options: PermissionOption[]; + timeout: number; +} + +export type AcpPermissionDecision = + | { type: 'allow'; optionId?: string; always?: boolean } + | { type: 'reject'; optionId?: string; always?: boolean } + | { type: 'timeout' } + | { type: 'cancelled' }; + +export const AcpPermissionServicePath = 'AcpPermissionServicePath'; + +/** + * Browser-side RPC service interface + * Called from Node layer to show permission dialogs + */ +export interface IAcpPermissionService { + $showPermissionDialog(params: AcpPermissionDialogParams): Promise; + $cancelRequest(requestId: string): Promise; +} + +export const AcpPermissionServiceToken = Symbol('AcpPermissionServiceToken'); + +/** + * Node-side caller interface (for internal use) + * This is what Node layer uses to call browser + * Implemented by AcpPermissionCallerManager (multi-instance, per clientId) + */ +export interface IAcpPermissionCaller { + requestPermission(request: RequestPermissionRequest): Promise; + cancelRequest(requestId: string): Promise; +} + +// ACP CLI Client Service Types + +/** + * Connection state for ACP CLI client + * Represents the lifecycle states of the JSON-RPC connection + */ +export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'disconnecting'; + +/** + * ACP CLI 客户端服务接口 - 基于 JSON-RPC 2.0 协议的传输层 + */ +export interface IAcpCliClientService { + /** + * Set up transport streams for JSON-RPC communication + * @param stdout - Readable stream from agent process + * @param stdin - Writable stream to agent process + */ + setTransport(stdout: NodeJS.ReadableStream, stdin: NodeJS.WritableStream): void; + + /** + * Initialize the ACP connection + */ + initialize(params?: InitializeRequest): Promise; + + /** + * Authenticate with the agent + */ + authenticate(params: AuthenticateRequest): Promise; + + /** + * Create a new session + */ + newSession(params: NewSessionRequest): Promise; + + /** + * Load an existing session + */ + loadSession(params: LoadSessionRequest): Promise; + + /** + * List all sessions + */ + listSessions(params?: ListSessionsRequest): Promise; + + /** + * Send a prompt to the session + */ + prompt(params: PromptRequest): Promise; + + /** + * Cancel an ongoing operation + */ + cancel(params: CancelNotification): Promise; + + /** + * Change the session mode + */ + setSessionMode(params: SetSessionModeRequest): Promise; + + /** + * Register a notification handler + * @returns Unsubscribe function + */ + onNotification(handler: (notification: SessionNotification) => void): () => void; + + /** + * Close the connection and cleanup resources + */ + close(): Promise; + + /** + * Check if currently connected + */ + isConnected(): boolean; + + /** + * Handle unexpected disconnect + */ + handleDisconnect(): void; + + /** + * Get the negotiated protocol version + */ + getNegotiatedProtocolVersion(): number | null; + + /** + * Get agent capabilities from initialize response + */ + getAgentCapabilities(): AgentCapabilities | null; + + /** + * Get agent info from initialize response + */ + getAgentInfo(): Implementation | null; + + /** + * Get available authentication methods + */ + getAuthMethods(): AuthMethod[]; + + /** + * Get available session modes + */ + getSessionModes(): SessionModeState | null; +} + +/** + * Symbol token for dependency injection + */ +export const AcpCliClientServiceToken = Symbol('AcpCliClientServiceToken'); diff --git a/packages/core-common/src/types/ai-native/agent-types.ts b/packages/core-common/src/types/ai-native/agent-types.ts new file mode 100644 index 0000000000..5df693fe66 --- /dev/null +++ b/packages/core-common/src/types/ai-native/agent-types.ts @@ -0,0 +1,87 @@ +/** + * ACP Agent Type Definitions + * Centralized configuration for supported CLI agents + */ + +// ACP Agent 类型 +export type ACPAgentType = 'qwen' | 'claude-agent-acp'; + +// Default agent type +export const DEFAULT_AGENT_TYPE: ACPAgentType = 'claude-agent-acp'; + +// Supported agent types +export enum ACPAgentTypeEnum { + Qwen = 'qwen', + ClaudeCodeACP = 'claude-agent-acp', +} + +// Agent configuration preset +export interface AgentConfig { + /** + * CLI command to start the agent + */ + command: string; + + /** + * Arguments passed to the agent + */ + args: string[]; + + /** + * Whether this agent supports streaming + */ + streaming?: boolean; + + /** + * Agent description for UI display + */ + description?: string; +} + +// Agent configuration presets +export const AGENT_CONFIGS: Record = { + qwen: { + command: 'qwen', + args: ['--acp', '--channel=ACP', '--input-format=stream-json', '--output-format=stream-json'], + streaming: true, + description: 'Qwen CLI Agent', + }, + 'claude-agent-acp': { + command: 'claude-agent-acp', + args: [], + streaming: true, + description: 'Claude Code ACP Agent', + }, +}; + +/** + * Get agent configuration for a given type + */ +export function getAgentConfig(agentType: ACPAgentType): AgentConfig { + return AGENT_CONFIGS[agentType] || AGENT_CONFIGS[DEFAULT_AGENT_TYPE]; +} + +/** + * Check if an agent type is supported + */ +export function isSupportedAgentType(type: string): type is ACPAgentType { + return type in AGENT_CONFIGS; +} + +/** + * Get list of all supported agent types + */ +export function getSupportedAgentTypes(): ACPAgentType[] { + return Object.keys(AGENT_CONFIGS) as ACPAgentType[]; +} + +/** + * Configuration for spawning and running the ACP CLI agent process. + * Used to initialize the agent connection and process, not to configure individual sessions. + */ +export interface AgentProcessConfig { + agentType: ACPAgentType; + workspaceDir: string; + env?: Record; + enablePermissionConfirmation?: boolean; +} diff --git a/packages/core-common/src/types/ai-native/index.ts b/packages/core-common/src/types/ai-native/index.ts index e1519b943b..bb8b1c47f7 100644 --- a/packages/core-common/src/types/ai-native/index.ts +++ b/packages/core-common/src/types/ai-native/index.ts @@ -4,6 +4,8 @@ import { SumiReadableStream } from '@opensumi/ide-utils/lib/stream'; import { FileType } from '../file'; import { IMarkdownString } from '../markdown'; +import { ListSessionsResponse } from './acp-types'; +import { AgentProcessConfig } from './agent-types'; import { IAIReportCompletionOption } from './reporter'; import type { CoreMessage } from 'ai'; @@ -58,6 +60,10 @@ export interface IAINativeCapabilities { * supports modelcontextprotocol */ supportsMCP?: boolean; + /** + * supports agent mode for chat input + */ + supportsAgentMode?: boolean; } export interface IDesignLayoutConfig { @@ -188,6 +194,7 @@ export interface IAIBackServiceOption { /** 响应首尾是否有需要trim的内容 */ trimTexts?: [string, string]; disabledTools?: string[]; + agentSessionConfig?: AgentProcessConfig; } /** @@ -247,6 +254,26 @@ export interface IAIBackService< * @deprecated */ reportCompletion?(input: I): Promise; + + loadAgentSession?( + config: AgentProcessConfig, + agentSessionId: string, + ): Promise<{ + sessionId: string; + messages: Array<{ + role: 'user' | 'assistant'; + content: string; + timestamp?: number; + }>; + }>; + + listSessions?(config: AgentProcessConfig): Promise; + + createSession?(config: AgentProcessConfig): Promise<{ + sessionId: string; + }>; + + setSessionMode?(sessionId: string, modeId: string): Promise; } export class ReplyResponse { @@ -467,3 +494,6 @@ export enum ECodeEditsSourceTyping { Trigger = 'trigger', } // ## Code Edits ends ## + +export * from './acp-types'; +export * from './agent-types'; diff --git a/packages/file-service/package.json b/packages/file-service/package.json index 97b5b62b3f..427ace80a8 100644 --- a/packages/file-service/package.json +++ b/packages/file-service/package.json @@ -18,6 +18,7 @@ "url": "git@github.com:opensumi/core.git" }, "dependencies": { + "@furyjs/fury": "0.5.9-beta", "@opensumi/ide-connection": "workspace:*", "@opensumi/ide-core-common": "workspace:*", "@opensumi/ide-core-node": "workspace:*", diff --git a/packages/startup/entry/sample-modules/ai-native/ai-native.contribution.ts b/packages/startup/entry/sample-modules/ai-native/ai-native.contribution.ts index 6700757ba0..c733c2b3ee 100644 --- a/packages/startup/entry/sample-modules/ai-native/ai-native.contribution.ts +++ b/packages/startup/entry/sample-modules/ai-native/ai-native.contribution.ts @@ -557,12 +557,7 @@ Good: "Instance network interfaces exceeded system limit"`; }); } - registerChatAgentPromptProvider(): void { - this.injector.overrideProviders({ - token: ChatAgentPromptProvider, - useClass: DefaultChatAgentPromptProvider, - }); - } + registerChatAgentPromptProvider(): void {} } const MAX_IMAGE_SIZE = 3 * 1024 * 1024; diff --git a/packages/startup/entry/web/server.ts b/packages/startup/entry/web/server.ts index bb94ec8193..88d1508fbb 100644 --- a/packages/startup/entry/web/server.ts +++ b/packages/startup/entry/web/server.ts @@ -15,10 +15,10 @@ import { CommonNodeModules } from '../../src/node/common-modules'; import { AIBackService } from '../sample-modules/ai-native/ai.back.service'; const injectorProviders: Provider[] = [ - { - token: AIBackSerivceToken, - useClass: AIBackService, - }, + // { + // token: AIBackSerivceToken, + // useClass: AIBackService, + // }, ]; // Only override terminal pty manager to use remote proxy when env is provided. diff --git a/yarn.lock b/yarn.lock index 36e04defaa..0f0be2d976 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12,6 +12,15 @@ __metadata: languageName: node linkType: hard +"@agentclientprotocol/sdk@npm:^0.16.1": + version: 0.16.1 + resolution: "@agentclientprotocol/sdk@npm:0.16.1" + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + checksum: 10/565495a1024712423ddac966a944ae03ee1e54592504be74cc538250979e907f91550f7ba9fef080aac4cc15de430ee44bdd3bda32009a2735513d4024cec1f5 + languageName: node + linkType: hard + "@ai-sdk/anthropic@npm:^1.1.9": version: 1.1.9 resolution: "@ai-sdk/anthropic@npm:1.1.9" @@ -3414,6 +3423,7 @@ __metadata: version: 0.0.0-use.local resolution: "@opensumi/ide-ai-native@workspace:packages/ai-native" dependencies: + "@agentclientprotocol/sdk": "npm:^0.16.1" "@ai-sdk/anthropic": "npm:^1.1.9" "@ai-sdk/deepseek": "npm:^0.1.11" "@ai-sdk/openai": "npm:^1.1.9" @@ -3449,6 +3459,7 @@ __metadata: diff: "npm:^7.0.0" dom-align: "npm:^1.7.0" eventsource: "npm:^3.0.5" + node-pty: "npm:1.0.0" rc-collapse: "npm:^4.0.0" react-chat-elements: "npm:^12.0.10" react-highlight: "npm:^0.15.0" @@ -3581,11 +3592,13 @@ __metadata: version: 0.0.0-use.local resolution: "@opensumi/ide-core-common@workspace:packages/core-common" dependencies: + "@agentclientprotocol/sdk": "npm:^0.16.1" "@opensumi/di": "npm:^1.8.0" "@opensumi/events": "npm:^1.0.0" "@opensumi/ide-dev-tool": "workspace:*" "@opensumi/ide-utils": "workspace:*" ai: "npm:^4.3.16" + electron: "npm:^22.3.21" languageName: unknown linkType: soft @@ -3890,6 +3903,7 @@ __metadata: version: 0.0.0-use.local resolution: "@opensumi/ide-file-service@workspace:packages/file-service" dependencies: + "@furyjs/fury": "npm:0.5.9-beta" "@opensumi/ide-connection": "workspace:*" "@opensumi/ide-core-browser": "workspace:*" "@opensumi/ide-core-common": "workspace:*"