diff --git a/.gitignore b/.gitignore index 26c7cf18ce..a2bdfb76cc 100644 --- a/.gitignore +++ b/.gitignore @@ -99,4 +99,8 @@ tools/workspace .ipynb_checkpoints *.tsbuildinfo -.env \ No newline at end of file +.env + +# Claude Code +.claude/ +.claudebak diff --git a/packages/ai-native/__test__/browser/acp/build-agent-process-config.test.ts b/packages/ai-native/__test__/browser/acp/build-agent-process-config.test.ts new file mode 100644 index 0000000000..fb806aee65 --- /dev/null +++ b/packages/ai-native/__test__/browser/acp/build-agent-process-config.test.ts @@ -0,0 +1,130 @@ +import { EnvVariable, McpServer } from '@opensumi/ide-core-common/lib/types/ai-native/acp-types'; + +import { buildAcpAgentProcessConfig } from '../../../lib/browser/acp/build-agent-process-config'; + +describe('buildAcpAgentProcessConfig', () => { + const defaultRegistration = { + command: '/usr/local/bin/agent', + args: ['--stdio'], + env: [{ name: 'API_KEY', value: 'default' }] as EnvVariable[], + cwd: '/workspace', + }; + + const defaultPrefs = { + nodePath: '', + agents: {}, + }; + + it('returns registration values when user has no overrides', () => { + const result = buildAcpAgentProcessConfig({ + agentId: 'test-agent', + registration: defaultRegistration, + userPreferences: defaultPrefs, + }); + expect(result).toEqual({ + agentId: 'test-agent', + command: '/usr/local/bin/agent', + args: ['--stdio'], + env: [{ name: 'API_KEY', value: 'default' }], + cwd: '/workspace', + nodePath: undefined, + }); + }); + + it('overrides command when user provides it', () => { + const result = buildAcpAgentProcessConfig({ + agentId: 'test-agent', + registration: defaultRegistration, + userPreferences: { + ...defaultPrefs, + agents: { 'test-agent': { command: '/custom/bin/agent' } }, + }, + }); + expect(result.command).toBe('/custom/bin/agent'); + expect(result.args).toEqual(['--stdio']); + }); + + it('REPLACES args when user provides them', () => { + const result = buildAcpAgentProcessConfig({ + agentId: 'test-agent', + registration: defaultRegistration, + userPreferences: { + ...defaultPrefs, + agents: { 'test-agent': { args: ['--debug', '--verbose'] } }, + }, + }); + expect(result.args).toEqual(['--debug', '--verbose']); + }); + + it('MERGE env: user keys override registration defaults', () => { + const result = buildAcpAgentProcessConfig({ + agentId: 'test-agent', + registration: { + ...defaultRegistration, + env: [ + { name: 'API_KEY', value: 'default' }, + { name: 'KEEP', value: 'yes' }, + ], + }, + userPreferences: { + ...defaultPrefs, + agents: { + 'test-agent': { env: { API_KEY: 'user-value', NEW_KEY: 'new' } }, + }, + }, + }); + const envMap = new Map(result.env!.map((v) => [v.name, v.value])); + expect(envMap.get('API_KEY')).toBe('user-value'); + expect(envMap.get('KEEP')).toBe('yes'); + expect(envMap.get('NEW_KEY')).toBe('new'); + }); + + it('uses registration defaults when agentId not in user map', () => { + const result = buildAcpAgentProcessConfig({ + agentId: 'unknown-agent', + registration: defaultRegistration, + userPreferences: { + ...defaultPrefs, + agents: { 'other-agent': { command: '/x' } }, + }, + }); + expect(result.command).toBe('/usr/local/bin/agent'); + expect(result.args).toEqual(['--stdio']); + }); + + it('sets nodePath when user provides it', () => { + const result = buildAcpAgentProcessConfig({ + agentId: 'test-agent', + registration: defaultRegistration, + userPreferences: { nodePath: '/usr/local/bin/node', agents: {} }, + }); + expect(result.nodePath).toBe('/usr/local/bin/node'); + }); + + it('sets nodePath to undefined when user preference is empty string', () => { + const result = buildAcpAgentProcessConfig({ + agentId: 'test-agent', + registration: defaultRegistration, + userPreferences: { nodePath: '', agents: {} }, + }); + expect(result.nodePath).toBeUndefined(); + }); + + it('includes ACP MCP servers when provided', () => { + const mcpServers: McpServer[] = [ + { + name: 'filesystem', + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-filesystem', '/workspace'], + env: [], + }, + ]; + const result = buildAcpAgentProcessConfig({ + agentId: 'test-agent', + registration: defaultRegistration, + userPreferences: defaultPrefs, + mcpServers, + }); + expect(result.mcpServers).toBe(mcpServers); + }); +}); diff --git a/packages/ai-native/__test__/browser/acp/permission-bridge-session.test.ts b/packages/ai-native/__test__/browser/acp/permission-bridge-session.test.ts new file mode 100644 index 0000000000..1a820f5b54 --- /dev/null +++ b/packages/ai-native/__test__/browser/acp/permission-bridge-session.test.ts @@ -0,0 +1,308 @@ +import { + AcpPermissionBridgeService, + ShowPermissionDialogParams, +} from '../../../src/browser/acp/permission-bridge.service'; +import { PermissionDialogManager } from '../../../src/browser/acp/permission-dialog-container'; + +// Mock @opensumi/di to make decorators no-ops +jest.mock('@opensumi/di', () => { + const actual = jest.requireActual('@opensumi/di'); + const noopDecorator = () => () => {}; + return { + ...actual, + Injectable: () => (cls: any) => cls, + Autowired: noopDecorator, + Inject: noopDecorator, + Optional: noopDecorator, + }; +}); + +// Mock dependencies +const mockLogger = { + log: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + verbose: jest.fn(), + warn: jest.fn(), +}; + +const mockMainLayoutService = {}; + +describe('AcpPermissionBridgeService - session binding', () => { + let service: AcpPermissionBridgeService; + + const mockParams: ShowPermissionDialogParams = { + requestId: 'session-1:tool-1', + sessionId: 'session-1', + title: 'Test permission', + kind: 'write', + content: 'Edit file.txt', + locations: [{ path: '/workspace/file.txt' }], + options: [ + { optionId: 'allow_once', name: 'Allow Once', kind: 'allow_once' }, + { optionId: 'reject_once', name: 'Reject', kind: 'reject_once' }, + ], + timeout: 5000, + }; + + beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllMocks(); + + service = new AcpPermissionBridgeService(); + Object.defineProperty(service, 'logger', { value: mockLogger, writable: true }); + Object.defineProperty(service, 'mainLayoutService', { value: mockMainLayoutService, writable: true }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('setActiveSession / getActiveSession', () => { + it('should track the active session', () => { + service.setActiveSession('session-1'); + expect(service.getActiveSession()).toBe('session-1'); + + service.setActiveSession('session-2'); + expect(service.getActiveSession()).toBe('session-2'); + }); + + it('should return undefined initially', () => { + expect(service.getActiveSession()).toBeUndefined(); + }); + + it('should accept undefined to clear session', () => { + service.setActiveSession('session-1'); + service.setActiveSession(undefined); + expect(service.getActiveSession()).toBeUndefined(); + }); + }); + + describe('onActiveSessionChange', () => { + it('should fire event when session changes', () => { + const listener = jest.fn(); + const dispose = service.onActiveSessionChange(listener); + + service.setActiveSession('session-1'); + expect(listener).toHaveBeenCalledWith('session-1'); + + dispose.dispose(); + }); + + it('should not fire event when session is the same', () => { + const listener = jest.fn(); + const dispose = service.onActiveSessionChange(listener); + + service.setActiveSession('session-1'); + expect(listener).toHaveBeenCalledTimes(1); + + service.setActiveSession('session-1'); + expect(listener).toHaveBeenCalledTimes(1); + + dispose.dispose(); + }); + + it('should fire with undefined when clearing session', () => { + const listener = jest.fn(); + const dispose = service.onActiveSessionChange(listener); + + service.setActiveSession('session-1'); + service.setActiveSession(undefined); + expect(listener).toHaveBeenLastCalledWith(undefined); + + dispose.dispose(); + }); + }); + + describe('showPermissionDialog without auto-timeout', () => { + it('should not auto-resolve after timeout period', async () => { + service.setActiveSession('session-1'); + + const promise = service.showPermissionDialog({ + ...mockParams, + requestId: 'session-1:tool-timeout', + timeout: 100, // 100ms - should NOT auto-resolve + }); + + // Advance time beyond the timeout + jest.advanceTimersByTime(200); + + // The promise should still be pending + expect((service as any).pendingDecisions.has('session-1:tool-timeout')).toBe(true); + + // Now manually resolve + service.handleDialogClose('session-1:tool-timeout'); + const result = await promise; + expect(result.type).toBe('timeout'); + }); + + it('should persist dialog until explicitly resolved', async () => { + service.setActiveSession('session-1'); + + const promise = service.showPermissionDialog({ + ...mockParams, + requestId: 'session-1:tool-persist', + timeout: 60000, // 60s default + }); + + // Advance time by 60 seconds - dialog should still be pending + jest.advanceTimersByTime(60000); + expect((service as any).pendingDecisions.has('session-1:tool-persist')).toBe(true); + + // Advance another 60 seconds - still pending + jest.advanceTimersByTime(60000); + expect((service as any).pendingDecisions.has('session-1:tool-persist')).toBe(true); + + // Resolve manually + service.handleUserDecision('session-1:tool-persist', 'allow_once', 'allow_once'); + const result = await promise; + expect(result.type).toBe('allow'); + }); + }); +}); + +describe('PermissionDialogManager - session-scoped dialogs', () => { + let manager: PermissionDialogManager; + + const makeParams = (sessionId: string, toolId: string): ShowPermissionDialogParams => ({ + requestId: `${sessionId}:${toolId}`, + sessionId, + title: `Test ${toolId}`, + kind: 'write', + options: [], + timeout: 5000, + }); + + beforeEach(() => { + manager = new PermissionDialogManager(); + }); + + describe('getDialogsForSession', () => { + it('should return empty array for undefined sessionId', () => { + manager.addDialog(makeParams('session-1', 'tool-1')); + expect(manager.getDialogsForSession(undefined)).toEqual([]); + }); + + it('should return only dialogs for the specified session', () => { + manager.addDialog(makeParams('session-1', 'tool-1')); + manager.addDialog(makeParams('session-2', 'tool-2')); + manager.addDialog(makeParams('session-1', 'tool-3')); + + const dialogs = manager.getDialogsForSession('session-1'); + expect(dialogs).toHaveLength(2); + expect(dialogs[0].params.sessionId).toBe('session-1'); + expect(dialogs[1].params.sessionId).toBe('session-1'); + }); + + it('should return empty array when no dialogs match session', () => { + manager.addDialog(makeParams('session-1', 'tool-1')); + expect(manager.getDialogsForSession('session-99')).toEqual([]); + }); + }); + + describe('clearDialogsForSession', () => { + it('should remove all dialogs for the specified session', () => { + manager.addDialog(makeParams('session-1', 'tool-1')); + manager.addDialog(makeParams('session-2', 'tool-2')); + manager.addDialog(makeParams('session-1', 'tool-3')); + + manager.clearDialogsForSession('session-1'); + + const remaining = manager.getDialogs(); + expect(remaining).toHaveLength(1); + expect(remaining[0].params.sessionId).toBe('session-2'); + }); + + it('should do nothing for undefined sessionId', () => { + manager.addDialog(makeParams('session-1', 'tool-1')); + manager.clearDialogsForSession(undefined); + expect(manager.getDialogs()).toHaveLength(1); + }); + + it('should notify listeners after clearing', () => { + const listener = jest.fn(); + manager.subscribe(listener); + + manager.addDialog(makeParams('session-1', 'tool-1')); + manager.clearDialogsForSession('session-1'); + + expect(listener).toHaveBeenCalledTimes(2); + }); + }); +}); + +describe('AcpPermissionBridgeService - clearSessionDialogs', () => { + let service: AcpPermissionBridgeService; + + const mockParams: ShowPermissionDialogParams = { + requestId: 'session-1:tool-1', + sessionId: 'session-1', + title: 'Test permission', + kind: 'write', + content: 'Edit file.txt', + locations: [{ path: '/workspace/file.txt' }], + options: [ + { optionId: 'allow_once', name: 'Allow Once', kind: 'allow_once' }, + { optionId: 'reject_once', name: 'Reject', kind: 'reject_once' }, + ], + timeout: 5000, + }; + + beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllMocks(); + + service = new AcpPermissionBridgeService(); + Object.defineProperty(service, 'logger', { value: mockLogger, writable: true }); + Object.defineProperty(service, 'mainLayoutService', { value: mockMainLayoutService, writable: true }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should clear active dialogs for the given session', () => { + service.showPermissionDialog({ + ...mockParams, + requestId: 'session-1:tool-1', + }); + service.showPermissionDialog({ + ...mockParams, + requestId: 'session-2:tool-2', + sessionId: 'session-2', + }); + + expect(service.getActiveDialogCount()).toBe(2); + service.clearSessionDialogs('session-1'); + expect(service.getActiveDialogCount()).toBe(1); + }); + + it('should clear pending decisions for the given session with cancelled result', async () => { + const promise1 = service.showPermissionDialog({ + ...mockParams, + requestId: 'session-1:tool-1', + }); + const promise2 = service.showPermissionDialog({ + ...mockParams, + requestId: 'session-2:tool-2', + sessionId: 'session-2', + }); + + expect(service.getActiveDialogCount()).toBe(2); + + service.clearSessionDialogs('session-1'); + + expect(service.getActiveDialogCount()).toBe(1); + expect(await promise1).toEqual({ type: 'cancelled' }); + expect((service as any).pendingDecisions.has('session-2:tool-2')).toBe(true); + + service.handleDialogClose('session-2:tool-2'); + expect(await promise2).toEqual({ type: 'timeout' }); + }); + + it('should do nothing for sessions with no dialogs', () => { + service.showPermissionDialog(mockParams); + service.clearSessionDialogs('non-existent-session'); + expect(service.getActiveDialogCount()).toBe(1); + }); +}); diff --git a/packages/ai-native/__test__/browser/permission-dialog-ui.test.tsx b/packages/ai-native/__test__/browser/permission-dialog-ui.test.tsx new file mode 100644 index 0000000000..73ed480528 --- /dev/null +++ b/packages/ai-native/__test__/browser/permission-dialog-ui.test.tsx @@ -0,0 +1,436 @@ +/** + * Tests for PermissionDialogWidget rendering and keyboard accessibility. + * + * Uses raw React + DOM APIs since @testing-library/react is not installed. + * + * Verifies: + * - data-testid attributes are present for ui_assert + * - Options render correctly + * - Keyboard navigation (ArrowUp/ArrowDown/Enter/Escape) works + * - Dialog closes on decision or close button click + */ +import * as React from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; +import { act } from 'react-dom/test-utils'; + +import { PermissionDialogWidget } from '../../src/browser/components/permission-dialog-widget'; + +// Mock the services that PermissionDialogWidget depends on +// These must be mocked before the component is imported to avoid DI decorator issues +jest.mock('../../src/browser/acp/permission-bridge.service', () => ({ + AcpPermissionBridgeService: jest.fn(), +})); + +jest.mock('../../src/browser/acp/permission-dialog-container', () => ({ + PermissionDialogManager: jest.fn(), +})); + +// Mock the Less module +jest.mock('../../src/browser/components/permission-dialog-widget.module.less', () => ({ + permission_dialog_container: 'permission_dialog_container', + permission_dialog: 'permission_dialog', + header: 'header', + has_content: 'has_content', + title: 'title', + warning_icon: 'warning_icon', + close_button: 'close_button', + content: 'content', + options: 'options', + option_button: 'option_button', + option_key: 'option_key', + option_text: 'option_text', +})); + +// Mock core-browser injectable +jest.mock('@opensumi/ide-core-browser', () => ({ + useInjectable: jest.fn(), +})); + +jest.mock('@opensumi/ide-core-browser/lib/components', () => ({ + getIcon: (name: string) => `icon-${name}`, +})); + +function createMockDialogManager(initialDialogs: any[] = []) { + const listeners: Array<(dialogs: any[]) => void> = []; + let dialogs = [...initialDialogs]; + + return { + subscribe: jest.fn((fn: (d: any[]) => void) => { + listeners.push(fn); + return () => {}; + }), + getDialogs: jest.fn(() => [...dialogs]), + addDialog: jest.fn((d: any) => { + dialogs.push(d); + listeners.forEach((fn) => fn([...dialogs])); + }), + removeDialog: jest.fn((requestId: string) => { + dialogs = dialogs.filter((d) => d.requestId !== requestId); + listeners.forEach((fn) => fn([...dialogs])); + }), + clearAll: jest.fn(() => { + dialogs = []; + listeners.forEach((fn) => fn([])); + }), + getDialogsForSession: jest.fn((sessionId: string | undefined) => { + if (!sessionId) { + return []; + } + return dialogs.filter((d) => d.params.sessionId === sessionId); + }), + clearDialogsForSession: jest.fn(), + }; +} + +function createMockPermissionBridgeService() { + const listeners: Array<(sessionId: string | undefined) => void> = []; + let activeSessionId: string | undefined = 'test-session'; + + return { + onActiveSessionChange: jest.fn((fn: (id: string | undefined) => void) => { + listeners.push(fn); + return { dispose: jest.fn() }; + }), + getActiveSession: jest.fn(() => activeSessionId), + setActiveSession: jest.fn((id: string | undefined) => { + activeSessionId = id; + listeners.forEach((fn) => fn(id)); + }), + handleUserDecision: jest.fn(), + handleDialogClose: jest.fn(), + onDidRequestPermission: { event: jest.fn() }, + onDidReceivePermissionResult: { event: jest.fn() }, + }; +} + +const mockPermissionBridge = createMockPermissionBridgeService(); + +const editDialogParams = { + requestId: 'req-edit-1', + sessionId: 'test-session', + title: 'Edit Permission', + kind: 'edit', + content: 'Write to file: src/index.ts', + locations: [{ path: 'src/index.ts', line: 10 }], + options: [ + { optionId: 'allow_once', name: 'Allow Once', kind: 'allow_once' }, + { optionId: 'allow_always', name: 'Always Allow', kind: 'allow_always' }, + { optionId: 'reject', name: 'Reject', kind: 'reject' }, + ], + timeout: 60000, +}; + +const executeDialogParams = { + requestId: 'req-exec-1', + sessionId: 'test-session', + title: 'Execute Permission', + kind: 'execute', + command: 'rm -rf /tmp/test', + options: [ + { optionId: 'allow_once', name: 'Allow Once', kind: 'allow_once' }, + { optionId: 'reject', name: 'Reject', kind: 'reject' }, + ], + timeout: 60000, +}; + +describe('PermissionDialogWidget - Rendering', () => { + let container: HTMLDivElement; + let dialogManager: ReturnType; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + jest.clearAllMocks(); + (mockPermissionBridge as any).getActiveSession.mockReturnValue('test-session'); + jest.requireMock('@opensumi/ide-core-browser').useInjectable.mockReturnValue(mockPermissionBridge); + }); + + afterEach(() => { + unmountComponentAtNode(container); + container.remove(); + }); + + it('renders null when no dialogs exist', () => { + dialogManager = createMockDialogManager([]); + act(() => { + render(React.createElement(PermissionDialogWidget, { dialogManager, bottom: 40 }), container); + }); + expect(container.innerHTML).toBe(''); + }); + + it('renders dialog with all data-testid attributes', () => { + dialogManager = createMockDialogManager([{ requestId: editDialogParams.requestId, params: editDialogParams }]); + act(() => { + render(React.createElement(PermissionDialogWidget, { dialogManager, bottom: 40 }), container); + }); + + expect(container.querySelector('[data-testid="acp-permission-dialog"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="acp-permission-dialog-title"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="acp-permission-dialog-content"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="acp-permission-dialog-options"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="acp-permission-dialog-close"]')).not.toBeNull(); + }); + + it('renders option buttons with indexed data-testid', () => { + dialogManager = createMockDialogManager([{ requestId: editDialogParams.requestId, params: editDialogParams }]); + act(() => { + render(React.createElement(PermissionDialogWidget, { dialogManager, bottom: 40 }), container); + }); + + expect(container.querySelector('[data-testid="acp-permission-dialog-option-0"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="acp-permission-dialog-option-1"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="acp-permission-dialog-option-2"]')).not.toBeNull(); + }); + + it('renders correct title for edit kind', () => { + dialogManager = createMockDialogManager([{ requestId: editDialogParams.requestId, params: editDialogParams }]); + act(() => { + render(React.createElement(PermissionDialogWidget, { dialogManager, bottom: 40 }), container); + }); + + const titleEl = container.querySelector('[data-testid="acp-permission-dialog-title"]'); + expect(titleEl?.textContent).toContain('Make this edit to'); + expect(titleEl?.textContent).toContain('index.ts'); + }); + + it('renders correct title for execute kind', () => { + dialogManager = createMockDialogManager([ + { requestId: executeDialogParams.requestId, params: executeDialogParams }, + ]); + act(() => { + render(React.createElement(PermissionDialogWidget, { dialogManager, bottom: 40 }), container); + }); + + const titleEl = container.querySelector('[data-testid="acp-permission-dialog-title"]'); + expect(titleEl?.textContent).toContain('Allow this bash command?'); + }); + + it('shows option names from params', () => { + dialogManager = createMockDialogManager([{ requestId: editDialogParams.requestId, params: editDialogParams }]); + act(() => { + render(React.createElement(PermissionDialogWidget, { dialogManager, bottom: 40 }), container); + }); + + expect(container.textContent).toContain('Allow Once'); + expect(container.textContent).toContain('Always Allow'); + expect(container.textContent).toContain('Reject'); + }); + + it('uses optionId as fallback when name is missing', () => { + const dialogWithoutNames = { + requestId: 'req-no-name', + params: { + ...editDialogParams, + options: [ + { optionId: 'allow_once', kind: 'allow_once' }, + { optionId: 'reject', kind: 'reject' }, + ], + }, + }; + dialogManager = createMockDialogManager([dialogWithoutNames]); + act(() => { + render(React.createElement(PermissionDialogWidget, { dialogManager, bottom: 40 }), container); + }); + + expect(container.textContent).toContain('allow_once'); + expect(container.textContent).toContain('reject'); + }); +}); + +describe('PermissionDialogWidget - Keyboard Navigation', () => { + let container: HTMLDivElement; + let dialogManager: ReturnType; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + jest.clearAllMocks(); + (mockPermissionBridge as any).getActiveSession.mockReturnValue('test-session'); + jest.requireMock('@opensumi/ide-core-browser').useInjectable.mockReturnValue(mockPermissionBridge); + }); + + afterEach(() => { + unmountComponentAtNode(container); + container.remove(); + }); + + function fireEventKeyDown(key: string) { + const event = new KeyboardEvent('keydown', { key }); + window.dispatchEvent(event); + } + + it('ArrowDown moves focus to next option', () => { + dialogManager = createMockDialogManager([{ requestId: editDialogParams.requestId, params: editDialogParams }]); + act(() => { + render(React.createElement(PermissionDialogWidget, { dialogManager, bottom: 40 }), container); + }); + + const firstOption = container.querySelector('[data-testid="acp-permission-dialog-option-0"]'); + expect(firstOption?.className).toContain('focused'); + + act(() => { + fireEventKeyDown('ArrowDown'); + }); + + const secondOption = container.querySelector('[data-testid="acp-permission-dialog-option-1"]'); + expect(secondOption?.className).toContain('focused'); + }); + + it('ArrowUp at first option stays at first', () => { + dialogManager = createMockDialogManager([{ requestId: editDialogParams.requestId, params: editDialogParams }]); + act(() => { + render(React.createElement(PermissionDialogWidget, { dialogManager, bottom: 40 }), container); + }); + + act(() => { + fireEventKeyDown('ArrowUp'); + }); + + const firstOption = container.querySelector('[data-testid="acp-permission-dialog-option-0"]'); + expect(firstOption?.className).toContain('focused'); + }); + + it('ArrowDown at last option stays at last', () => { + dialogManager = createMockDialogManager([{ requestId: editDialogParams.requestId, params: editDialogParams }]); + act(() => { + render(React.createElement(PermissionDialogWidget, { dialogManager, bottom: 40 }), container); + }); + + // Move to last option + act(() => { + fireEventKeyDown('ArrowDown'); + fireEventKeyDown('ArrowDown'); + }); + + const lastOption = container.querySelector('[data-testid="acp-permission-dialog-option-2"]'); + expect(lastOption?.className).toContain('focused'); + + // Stay at last + act(() => { + fireEventKeyDown('ArrowDown'); + }); + expect(lastOption?.className).toContain('focused'); + }); + + it('Enter triggers user decision on focused option', () => { + dialogManager = createMockDialogManager([{ requestId: editDialogParams.requestId, params: editDialogParams }]); + act(() => { + render(React.createElement(PermissionDialogWidget, { dialogManager, bottom: 40 }), container); + }); + + // Move to second option + act(() => { + fireEventKeyDown('ArrowDown'); + }); + + act(() => { + fireEventKeyDown('Enter'); + }); + + expect(mockPermissionBridge.handleUserDecision).toHaveBeenCalledWith('req-edit-1', 'allow_always', 'allow_always'); + expect(dialogManager.removeDialog).toHaveBeenCalledWith('req-edit-1'); + }); + + it('Escape triggers dialog close', () => { + dialogManager = createMockDialogManager([{ requestId: editDialogParams.requestId, params: editDialogParams }]); + act(() => { + render(React.createElement(PermissionDialogWidget, { dialogManager, bottom: 40 }), container); + }); + + act(() => { + fireEventKeyDown('Escape'); + }); + + expect(mockPermissionBridge.handleDialogClose).toHaveBeenCalledWith('req-edit-1'); + expect(dialogManager.removeDialog).toHaveBeenCalledWith('req-edit-1'); + }); + + it('close button click triggers dialog close', () => { + dialogManager = createMockDialogManager([{ requestId: editDialogParams.requestId, params: editDialogParams }]); + act(() => { + render(React.createElement(PermissionDialogWidget, { dialogManager, bottom: 40 }), container); + }); + + const closeBtn = container.querySelector('[data-testid="acp-permission-dialog-close"]'); + act(() => { + (closeBtn as HTMLElement)?.click(); + }); + + expect(mockPermissionBridge.handleDialogClose).toHaveBeenCalledWith('req-edit-1'); + expect(dialogManager.removeDialog).toHaveBeenCalledWith('req-edit-1'); + }); + + it('mouse enter changes focused option', () => { + dialogManager = createMockDialogManager([{ requestId: editDialogParams.requestId, params: editDialogParams }]); + act(() => { + render(React.createElement(PermissionDialogWidget, { dialogManager, bottom: 40 }), container); + }); + + const thirdOption = container.querySelector('[data-testid="acp-permission-dialog-option-2"]'); + // React's onMouseEnter uses mouseover/mouseout, not mouseenter/mouseleave + act(() => { + thirdOption?.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); + }); + + // Re-query after state update + const thirdOptionAfter = container.querySelector('[data-testid="acp-permission-dialog-option-2"]'); + expect(thirdOptionAfter?.className).toContain('focused'); + }); +}); + +describe('PermissionDialogWidget - Session Isolation', () => { + let container: HTMLDivElement; + let dialogManager: ReturnType; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + jest.clearAllMocks(); + jest.requireMock('@opensumi/ide-core-browser').useInjectable.mockReturnValue(mockPermissionBridge); + }); + + afterEach(() => { + unmountComponentAtNode(container); + container.remove(); + }); + + it('does not render dialogs from non-active session', () => { + (mockPermissionBridge as any).getActiveSession.mockReturnValue('active-session'); + + dialogManager = createMockDialogManager([ + { + requestId: 'req-other', + params: { ...editDialogParams, requestId: 'req-other', sessionId: 'other-session' }, + }, + ]); + act(() => { + render(React.createElement(PermissionDialogWidget, { dialogManager, bottom: 40 }), container); + }); + expect(container.innerHTML).toBe(''); + }); + + it('shows dialogs when session becomes active', () => { + const dialogManager2 = createMockDialogManager([ + { + requestId: 'req-target', + params: { ...editDialogParams, requestId: 'req-target', sessionId: 'target-session' }, + }, + ]); + + (mockPermissionBridge as any).getActiveSession.mockReturnValue('other-session'); + act(() => { + render(React.createElement(PermissionDialogWidget, { dialogManager: dialogManager2, bottom: 40 }), container); + }); + expect(container.innerHTML).toBe(''); + + // Simulate session change to target-session + (mockPermissionBridge as any).getActiveSession.mockReturnValue('target-session'); + const sessionChangeListeners = (mockPermissionBridge.onActiveSessionChange as jest.Mock).mock.calls[0]; + const sessionChangeListener = sessionChangeListeners[0]; + act(() => { + sessionChangeListener('target-session'); + }); + + expect(container.querySelector('[data-testid="acp-permission-dialog"]')).not.toBeNull(); + }); +}); diff --git a/packages/ai-native/__test__/browser/webmcp-tools.test.ts b/packages/ai-native/__test__/browser/webmcp-tools.test.ts new file mode 100644 index 0000000000..1302db862f --- /dev/null +++ b/packages/ai-native/__test__/browser/webmcp-tools.test.ts @@ -0,0 +1,373 @@ +import { ensureModelContext } from '@opensumi/ide-core-browser/lib/webmcp-polyfill'; + +import { registerAcpWebMCPTools } from '../../src/browser/acp/webmcp-tools.registry'; + +describe('WebMCP Tools - ACP', () => { + let disposable: { dispose: () => void }; + + beforeAll(() => { + ensureModelContext(); + const mockContainer = { + get: jest.fn().mockImplementation(() => { + throw new Error('DI token not mocked'); + }), + } as any; + disposable = registerAcpWebMCPTools(mockContainer); + }); + + afterAll(() => disposable.dispose()); + + describe('acp_listSessions', () => { + it('returns error when service unavailable', async () => { + const result = await navigator.modelContext!.executeTool('acp_listSessions', {}); + expect(result).toMatchObject({ success: false, error: 'SERVICE_UNAVAILABLE' }); + }); + }); + + describe('acp_createSession', () => { + it('returns error when service unavailable', async () => { + const result = await navigator.modelContext!.executeTool('acp_createSession', {}); + expect(result).toMatchObject({ success: false, error: 'SERVICE_UNAVAILABLE' }); + }); + }); + + describe('acp_switchSession', () => { + it('returns error when sessionId is missing', async () => { + const result = await navigator.modelContext!.executeTool('acp_switchSession', {}); + expect(result).toMatchObject({ success: false, error: 'INVALID_INPUT' }); + }); + + it('returns error when service unavailable', async () => { + const result = await navigator.modelContext!.executeTool('acp_switchSession', { sessionId: 'test-id' }); + expect(result).toMatchObject({ success: false, error: 'SERVICE_UNAVAILABLE' }); + }); + }); + + describe('acp_getSessionState', () => { + it('returns error when service unavailable', async () => { + const result = await navigator.modelContext!.executeTool('acp_getSessionState', {}); + expect(result).toMatchObject({ success: false, error: 'SERVICE_UNAVAILABLE' }); + }); + }); + + describe('acp_sendMessage', () => { + it('returns error when message is empty', async () => { + const result = await navigator.modelContext!.executeTool('acp_sendMessage', { message: '' }); + expect(result).toMatchObject({ success: false, error: 'INVALID_INPUT' }); + }); + + it('returns error when service unavailable', async () => { + const result = await navigator.modelContext!.executeTool('acp_sendMessage', { message: 'hello' }); + expect(result).toMatchObject({ success: false, error: 'SERVICE_UNAVAILABLE' }); + }); + }); + + describe('acp_clearSession', () => { + it('returns error when service unavailable', async () => { + const result = await navigator.modelContext!.executeTool('acp_clearSession', {}); + expect(result).toMatchObject({ success: false, error: 'SERVICE_UNAVAILABLE' }); + }); + }); + + describe('acp_cancelRequest', () => { + it('returns error when service unavailable', async () => { + const result = await navigator.modelContext!.executeTool('acp_cancelRequest', {}); + expect(result).toMatchObject({ success: false, error: 'SERVICE_UNAVAILABLE' }); + }); + }); + + describe('acp_getAvailableCommands', () => { + it('returns error when service unavailable', async () => { + const result = await navigator.modelContext!.executeTool('acp_getAvailableCommands', {}); + expect(result).toMatchObject({ success: false, error: 'SERVICE_UNAVAILABLE' }); + }); + }); + + describe('acp_setSessionMode', () => { + it('returns error when modeId is missing', async () => { + const result = await navigator.modelContext!.executeTool('acp_setSessionMode', {}); + expect(result).toMatchObject({ success: false, error: 'INVALID_INPUT' }); + }); + + it('returns error when service unavailable', async () => { + const result = await navigator.modelContext!.executeTool('acp_setSessionMode', { modeId: 'agent' }); + expect(result).toMatchObject({ success: false, error: 'SERVICE_UNAVAILABLE' }); + }); + }); + + describe('acp_showChatView', () => { + it('returns error when service unavailable', async () => { + const result = await navigator.modelContext!.executeTool('acp_showChatView', {}); + expect(result).toMatchObject({ success: false, error: 'SERVICE_UNAVAILABLE' }); + }); + }); + + describe('acp_getPermissionDialogState', () => { + it('returns error when service unavailable', async () => { + const result = await navigator.modelContext!.executeTool('acp_getPermissionDialogState', {}); + expect(result).toMatchObject({ success: false, error: 'SERVICE_UNAVAILABLE' }); + }); + }); + + describe('acp_handlePermissionDialog', () => { + it('returns error when requestId is missing', async () => { + const result = await navigator.modelContext!.executeTool('acp_handlePermissionDialog', { + optionId: 'allow_once', + }); + expect(result).toMatchObject({ success: false, error: 'INVALID_INPUT' }); + }); + + it('returns error when optionId is missing', async () => { + const result = await navigator.modelContext!.executeTool('acp_handlePermissionDialog', { requestId: 'req-1' }); + expect(result).toMatchObject({ success: false, error: 'INVALID_INPUT' }); + }); + + it('returns error when service unavailable', async () => { + const result = await navigator.modelContext!.executeTool('acp_handlePermissionDialog', { + requestId: 'req-1', + optionId: 'allow_once', + }); + expect(result).toMatchObject({ success: false, error: 'SERVICE_UNAVAILABLE' }); + }); + }); + + describe('getTools', () => { + it('returns all registered tools without execute functions', () => { + const tools = navigator.modelContext!.getTools(); + expect(tools.length).toBe(12); // 12 ACP tools + for (const tool of tools) { + expect(tool).not.toHaveProperty('execute'); + expect(tool.name).toMatch(/^acp_\w+$/); + } + }); + + it('contains expected tool names', () => { + const toolNames = navigator.modelContext!.getTools().map((t) => t.name); + expect(toolNames).toContain('acp_listSessions'); + expect(toolNames).toContain('acp_createSession'); + expect(toolNames).toContain('acp_switchSession'); + expect(toolNames).toContain('acp_getSessionState'); + expect(toolNames).toContain('acp_sendMessage'); + expect(toolNames).toContain('acp_clearSession'); + expect(toolNames).toContain('acp_cancelRequest'); + expect(toolNames).toContain('acp_getAvailableCommands'); + expect(toolNames).toContain('acp_setSessionMode'); + expect(toolNames).toContain('acp_showChatView'); + expect(toolNames).toContain('acp_getPermissionDialogState'); + expect(toolNames).toContain('acp_handlePermissionDialog'); + }); + }); +}); + +describe('WebMCP Tools - ACP (happy path)', () => { + let disposable: { dispose: () => void }; + let mockPermissionBridge: any; + + const mockSessions = [ + { sessionId: 'sess-1', title: 'Test Session', modelId: 'claude', threadStatus: 'idle', requests: [] }, + ]; + + const mockSessionModel = { + sessionId: 'sess-2', + title: 'New Session', + modelId: 'claude', + threadStatus: 'working', + requests: [{ message: { prompt: 'hello' } }], + }; + + function buildMockContainer() { + const mockInternalService = { + getSessions: jest.fn().mockReturnValue(mockSessions), + createSessionModel: jest.fn().mockResolvedValue(undefined), + activateSession: jest.fn().mockResolvedValue(undefined), + clearSessionModel: jest.fn().mockResolvedValue(undefined), + getAvailableCommands: jest.fn().mockReturnValue([{ name: '/explain', description: 'Explain code' }]), + setSessionMode: jest.fn().mockResolvedValue(undefined), + sessionModel: mockSessionModel, + }; + + const mockChatService = { + sendMessage: jest.fn(), + showChatView: jest.fn(), + }; + + const mockManagerService = { + cancelRequest: jest.fn(), + }; + + mockPermissionBridge = { + getActiveDialogCount: jest.fn().mockReturnValue(0), + getActiveSession: jest.fn().mockReturnValue('sess-2'), + handleUserDecision: jest.fn(), + }; + + return { + get: jest.fn().mockImplementation((token) => { + const tokenName = token?.toString?.() || String(token); + if (tokenName.includes('ChatInternalService')) { + return mockInternalService; + } + if (tokenName.includes('ChatService')) { + return mockChatService; + } + if (tokenName.includes('ChatManagerService')) { + return mockManagerService; + } + if (tokenName.includes('PermissionBridge')) { + return mockPermissionBridge; + } + throw new Error('DI token not mocked'); + }), + } as any; + } + + beforeAll(() => { + ensureModelContext(); + disposable = registerAcpWebMCPTools(buildMockContainer()); + }); + + afterAll(() => disposable.dispose()); + + describe('acp_listSessions', () => { + it('returns sessions list', async () => { + const result = await navigator.modelContext!.executeTool('acp_listSessions', {}); + expect(result).toMatchObject({ + success: true, + result: [{ sessionId: 'sess-1', title: 'Test Session' }], + }); + }); + }); + + describe('acp_createSession', () => { + it('creates a new session', async () => { + const result = await navigator.modelContext!.executeTool('acp_createSession', {}); + expect(result).toMatchObject({ + success: true, + result: { sessionId: 'sess-2', title: 'New Session' }, + }); + }); + }); + + describe('acp_switchSession', () => { + it('switches to specified session', async () => { + const result = await navigator.modelContext!.executeTool('acp_switchSession', { sessionId: 'sess-1' }); + expect(result).toMatchObject({ + success: true, + result: { sessionId: 'sess-2', title: 'New Session' }, + }); + }); + }); + + describe('acp_getSessionState', () => { + it('returns active session state with threadStatus', async () => { + const result = await navigator.modelContext!.executeTool('acp_getSessionState', {}); + expect(result).toMatchObject({ + success: true, + result: { + sessionId: 'sess-2', + threadStatus: 'working', + requestCount: 1, + }, + }); + }); + }); + + describe('acp_sendMessage', () => { + it('sends message to active session', async () => { + const result = await navigator.modelContext!.executeTool('acp_sendMessage', { message: 'hello' }); + expect(result).toMatchObject({ + success: true, + result: { sessionId: 'sess-2', status: 'message_sent' }, + }); + }); + + it('sends message with command', async () => { + const result = await navigator.modelContext!.executeTool('acp_sendMessage', { + message: 'explain this', + command: '/explain', + }); + expect(result.success).toBe(true); + }); + }); + + describe('acp_clearSession', () => { + it('clears the active session', async () => { + const result = await navigator.modelContext!.executeTool('acp_clearSession', {}); + expect(result).toMatchObject({ success: true }); + }); + }); + + describe('acp_cancelRequest', () => { + it('cancels the current request', async () => { + const result = await navigator.modelContext!.executeTool('acp_cancelRequest', {}); + expect(result).toMatchObject({ success: true, result: { status: 'cancelled' } }); + }); + }); + + describe('acp_getAvailableCommands', () => { + it('returns available commands', async () => { + const result = await navigator.modelContext!.executeTool('acp_getAvailableCommands', {}); + expect(result).toMatchObject({ + success: true, + result: [{ name: '/explain', description: 'Explain code' }], + }); + }); + }); + + describe('acp_setSessionMode', () => { + it('sets the session mode', async () => { + const result = await navigator.modelContext!.executeTool('acp_setSessionMode', { modeId: 'agent' }); + expect(result).toMatchObject({ success: true, result: { modeId: 'agent' } }); + }); + }); + + describe('acp_showChatView', () => { + it('shows the chat view', async () => { + const result = await navigator.modelContext!.executeTool('acp_showChatView', {}); + expect(result).toMatchObject({ success: true }); + }); + }); + + describe('acp_getPermissionDialogState', () => { + it('returns permission dialog state', async () => { + const result = await navigator.modelContext!.executeTool('acp_getPermissionDialogState', {}); + expect(result).toMatchObject({ + success: true, + result: { activeDialogCount: 0, activeSessionId: 'sess-2' }, + }); + }); + }); + + describe('acp_handlePermissionDialog', () => { + it('handles permission approval', async () => { + const result = await navigator.modelContext!.executeTool('acp_handlePermissionDialog', { + requestId: 'req-1', + optionId: 'allow_once', + }); + expect(result).toMatchObject({ + success: true, + result: { requestId: 'req-1', optionId: 'allow_once' }, + }); + expect(mockPermissionBridge.handleUserDecision).toHaveBeenCalledWith('req-1', 'allow_once', 'allow_once'); + }); + + it('handles permission rejection', async () => { + const result = await navigator.modelContext!.executeTool('acp_handlePermissionDialog', { + requestId: 'req-2', + optionId: 'reject', + }); + expect(result).toMatchObject({ + success: true, + result: { requestId: 'req-2', optionId: 'reject' }, + }); + }); + }); + + describe('tool disposal', () => { + it('returns TOOL_DISPOSED after dispose', async () => { + disposable.dispose(); + const result = await navigator.modelContext!.executeTool('acp_listSessions', {}); + expect(result).toMatchObject({ success: false, error: 'TOOL_DISPOSED' }); + }); + }); +}); diff --git a/packages/ai-native/__test__/node/acp-agent-request-handler.test.ts b/packages/ai-native/__test__/node/acp-agent-request-handler.test.ts index 7e22029315..90c3a5b286 100644 --- a/packages/ai-native/__test__/node/acp-agent-request-handler.test.ts +++ b/packages/ai-native/__test__/node/acp-agent-request-handler.test.ts @@ -27,9 +27,6 @@ const mockLogger = { const mockFileSystemHandler = { readTextFile: jest.fn(), writeTextFile: jest.fn(), - getFileMeta: jest.fn(), - listDirectory: jest.fn(), - createDirectory: jest.fn(), }; const mockTerminalHandler = { @@ -166,6 +163,7 @@ describe('AcpAgentRequestHandler', () => { kind: 'write', }), }), + 'sess-1', ); }); @@ -217,6 +215,7 @@ describe('AcpAgentRequestHandler', () => { title: expect.stringContaining('Run command'), }), }), + 'sess-1', ); }); diff --git a/packages/ai-native/__test__/node/acp-agent.service.test.ts b/packages/ai-native/__test__/node/acp-agent.service.test.ts index d5fb5f37b6..98a9d04d69 100644 --- a/packages/ai-native/__test__/node/acp-agent.service.test.ts +++ b/packages/ai-native/__test__/node/acp-agent.service.test.ts @@ -10,44 +10,12 @@ jest.mock('@opensumi/di', () => { }; }); -import { AgentProcessConfig } from '@opensumi/ide-core-common'; import { INodeLogger } from '@opensumi/ide-core-node'; import { AcpAgentService, AcpAgentServiceToken } from '../../src/node/acp/acp-agent.service'; import { AcpTerminalHandler, AcpTerminalHandlerToken } from '../../src/node/acp/handlers/terminal.handler'; -// Mock dependencies -const mockCliClientService = { - setTransport: jest.fn(), - initialize: jest.fn().mockResolvedValue(undefined), - newSession: jest.fn().mockResolvedValue({ - sessionId: 'test-session-123', - modes: { availableModes: [{ id: 'code', name: 'Code' }] }, - }), - loadSession: jest.fn().mockResolvedValue({}), - prompt: jest.fn().mockResolvedValue(undefined), - cancel: jest.fn(), - close: jest.fn().mockResolvedValue(undefined), - onNotification: jest.fn(() => jest.fn()) as any, - onDisconnect: jest.fn(() => jest.fn()), - listSessions: jest.fn(), - setSessionMode: jest.fn(), - getSessionModes: jest.fn(), -}; - -const mockProcessManager = { - startAgent: jest.fn().mockResolvedValue({ processId: 'proc-1', stdout: {} as any, stdin: {} as any }), - stopAgent: jest.fn().mockResolvedValue(undefined), - killAgent: jest.fn().mockResolvedValue(undefined), - killAllAgents: jest.fn().mockResolvedValue(undefined), - isRunning: jest.fn(), - getExitCode: jest.fn(), - listRunningAgents: jest.fn(), -}; - -const mockTerminalHandler = { - releaseSessionTerminals: jest.fn().mockResolvedValue(undefined), -}; +// ---- Mock dependencies ---- const mockLogger: INodeLogger = { log: jest.fn(), @@ -61,173 +29,462 @@ const mockLogger: INodeLogger = { setLevel: jest.fn(), } as unknown as INodeLogger; +const mockTerminalHandler = { + releaseSessionTerminals: jest.fn().mockResolvedValue(undefined), +}; + const mockAppConfig = {}; -const mockAgentProcessConfig: AgentProcessConfig = { +const mockPermissionRouting = { + registerSession: jest.fn(), + unregisterSession: jest.fn(), + routePermissionRequest: jest.fn(), + registeredSessions: new Map(), +}; + +const mockAgentProcessConfig = { + agentId: 'test-agent', command: 'npx', args: ['@anthropic-ai/claude-code@latest'], - workspaceDir: '/test/workspace', + cwd: '/test/workspace', + env: [], }; -function createService(): AcpAgentService { +// ---- Mock AcpThread factory ---- + +interface MockThread { + threadId: string; + sessionId: string; + initialized: boolean; + needsReset: boolean; + initialize: jest.Mock; + newSession: jest.Mock; + loadSession: jest.Mock; + loadSessionOrNew: jest.Mock; + prompt: jest.Mock; + cancel: jest.Mock; + listSessions: jest.Mock; + getEntries: jest.Mock; + getStatus: jest.Mock; + setStatus: jest.Mock; + setError: jest.Mock; + handleNotification: jest.Mock; + addUserMessage: jest.Mock; + markAssistantComplete: jest.Mock; + markToolCallWaiting: jest.Mock; + respondToToolCall: jest.Mock; + toAgentUpdate: jest.Mock; + setSessionMode: jest.Mock; + reset: jest.Mock; + dispose: jest.Mock; + onEvent: jest.Mock; + _fireEvent: (event: any) => void; + _eventListeners: Array<(event: any) => void>; +} + +function createMockThread(overrides: Record = {}): MockThread { + const eventListeners: Array<(event: any) => void> = []; + const base: MockThread = { + threadId: `thread-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + sessionId: '', + initialized: false, + needsReset: false, + initialize: jest.fn().mockResolvedValue({ protocolVersion: 1, agentCapabilities: {} }), + newSession: jest.fn().mockResolvedValue({ sessionId: 'new-session-1' }), + loadSession: jest.fn().mockResolvedValue({ sessionId: 'loaded-session-1' }), + loadSessionOrNew: jest.fn().mockResolvedValue({ sessionId: 'new-session-1' }), + prompt: jest.fn().mockResolvedValue({ stopReason: 'end_turn' }), + cancel: jest.fn().mockResolvedValue(undefined), + listSessions: jest.fn().mockResolvedValue({ sessions: [] }), + getEntries: jest.fn().mockReturnValue([]), + getStatus: jest.fn().mockReturnValue('idle'), + setStatus: jest.fn(), + setError: jest.fn(), + handleNotification: jest.fn(), + addUserMessage: jest.fn().mockReturnValue({ id: 'msg-1', content: '', timestamp: Date.now() }), + markAssistantComplete: jest.fn(), + markToolCallWaiting: jest.fn(), + respondToToolCall: jest.fn(), + toAgentUpdate: jest.fn().mockReturnValue({}), + setSessionMode: jest.fn().mockResolvedValue(undefined), + reset: jest.fn(), + dispose: jest.fn().mockResolvedValue(undefined), + onEvent: jest.fn((cb: any) => { + eventListeners.push(cb); + return { dispose: jest.fn(() => {}) }; + }), + _fireEvent(event: any) { + eventListeners.forEach((cb) => cb(event)); + }, + _eventListeners: eventListeners, + }; + return { ...base, ...overrides } as unknown as MockThread; +} + +function setupServiceWithMockFactory(mockFactory: jest.Mock) { const service = new AcpAgentService(); - Object.defineProperty(service, 'clientService', { value: mockCliClientService, writable: true }); - Object.defineProperty(service, 'processManager', { value: mockProcessManager, writable: true }); - Object.defineProperty(service, 'terminalHandler', { value: mockTerminalHandler, writable: true }); - Object.defineProperty(service, 'appConfig', { value: mockAppConfig, writable: true }); - Object.defineProperty(service, 'logger', { value: mockLogger, writable: true }); + (service as any).threadFactory = mockFactory; + (service as any).terminalHandler = mockTerminalHandler; + (service as any).appConfig = mockAppConfig; + (service as any).logger = mockLogger; + (service as any).permissionRouting = mockPermissionRouting; return service; } +function createService(): { service: AcpAgentService; mockFactory: jest.Mock; thread: MockThread } { + const thread = createMockThread(); + const mockFactory = jest.fn().mockReturnValue(thread); + const service = setupServiceWithMockFactory(mockFactory); + return { service, mockFactory, thread }; +} + +// Helper that fires available_commands_update immediately +function createServiceWithAutoEvents(): { service: AcpAgentService; mockFactory: jest.Mock; thread: MockThread } { + const eventListeners: Array<(event: any) => void> = []; + const thread = createMockThread({ + onEvent: jest.fn((cb: any) => { + eventListeners.push(cb); + return { dispose: jest.fn(() => {}) }; + }), + _fireEvent(event: any) { + eventListeners.forEach((cb) => cb(event)); + }, + _eventListeners: eventListeners, + }); + const mockFactory = jest.fn().mockReturnValue(thread); + const service = setupServiceWithMockFactory(mockFactory); + return { service, mockFactory, thread }; +} + beforeEach(() => { jest.clearAllMocks(); jest.useRealTimers(); }); -describe('AcpAgentService', () => { - describe('getSessionInfo()', () => { - it('should return null initially', () => { - const service = createService(); - expect(service.getSessionInfo()).toBeNull(); +// ============================================================================ +// Tests +// ============================================================================ + +describe('AcpAgentService (Thread Pool)', () => { + describe('Token', () => { + it('should export AcpAgentServiceToken as a symbol', () => { + expect(typeof AcpAgentServiceToken).toBe('symbol'); }); + }); - it('should return session info after initializeAgent', async () => { - const service = createService(); - await service.initializeAgent(mockAgentProcessConfig); - const info = service.getSessionInfo(); - expect(info).not.toBeNull(); - expect(info?.sessionId).toBe('test-session-123'); - expect(info?.processId).toBe('proc-1'); - expect(info?.status).toBe('ready'); + // ----------------------------------------------------------------------- + // createSession + // ----------------------------------------------------------------------- + + describe('createSession()', () => { + it('should create a new thread, initialize, and return sessionId with availableCommands', async () => { + const { service, thread } = createServiceWithAutoEvents(); + + // Fire available_commands_update event + setTimeout(() => { + thread._fireEvent({ + type: 'session_notification', + notification: { + sessionId: 'session-1', + update: { + sessionUpdate: 'available_commands_update', + availableCommands: [ + { name: 'ReadFile', description: 'Read a file' }, + { name: 'WriteFile', description: 'Write a file' }, + ], + }, + }, + }); + }, 10); + + const result = await service.createSession(mockAgentProcessConfig); + + expect(result.sessionId).toBeDefined(); + expect(result.availableCommands).toHaveLength(2); + expect(result.availableCommands[0].name).toBe('ReadFile'); + expect(thread.initialize).toHaveBeenCalled(); + expect(thread.loadSessionOrNew).toHaveBeenCalled(); + }); + + it('should throw when thread pool is full and no idle threads', async () => { + const { service, thread } = createServiceWithAutoEvents(); + + // Fill the pool with max threads (10) + const createdThreads: MockThread[] = []; + for (let i = 0; i < 10; i++) { + const t = createMockThread({ + getStatus: jest.fn().mockReturnValue('working'), + onEvent: jest.fn((cb: any) => { + setTimeout(() => { + cb({ + type: 'session_notification', + notification: { + sessionId: `session-${i}`, + update: { sessionUpdate: 'available_commands_update', availableCommands: [] }, + }, + }); + }, 10); + return { dispose: jest.fn() }; + }), + }); + createdThreads.push(t); + (service as any).threadFactory.mockReturnValueOnce(t); + await service.createSession(mockAgentProcessConfig); + } + + // Now try to create another session - should fail + const failThread = createMockThread(); + (service as any).threadFactory.mockReturnValue(failThread); + await expect(service.createSession(mockAgentProcessConfig)).rejects.toThrow('Thread pool is full'); + }); + + it('should clean up on error when thread was newly created', async () => { + const thread = createMockThread({ + onEvent: jest.fn(() => ({ dispose: jest.fn() })), + initialize: jest.fn().mockRejectedValue(new Error('Init failed')), + }); + const mockFactory = jest.fn().mockReturnValue(thread); + const service = setupServiceWithMockFactory(mockFactory); + + await expect(service.createSession(mockAgentProcessConfig)).rejects.toThrow('Init failed'); + expect(thread.dispose).toHaveBeenCalled(); }); }); + // ----------------------------------------------------------------------- + // initializeAgent + // ----------------------------------------------------------------------- + describe('initializeAgent()', () => { - it('should connect process, create session, and store sessionInfo', async () => { - const service = createService(); + it('should create a session and return AgentSessionInfo', async () => { + const { service, thread } = createServiceWithAutoEvents(); + + setTimeout(() => { + thread._fireEvent({ + type: 'session_notification', + notification: { + sessionId: 'session-1', + update: { sessionUpdate: 'available_commands_update', availableCommands: [] }, + }, + }); + }, 10); + const result = await service.initializeAgent(mockAgentProcessConfig); - expect(mockProcessManager.startAgent).toHaveBeenCalledWith( - 'npx', - ['@anthropic-ai/claude-code@latest'], - {}, - '/test/workspace', - ); - expect(mockCliClientService.setTransport).toHaveBeenCalled(); - expect(mockCliClientService.initialize).toHaveBeenCalled(); - expect(mockCliClientService.newSession).toHaveBeenCalledWith({ - cwd: '/test/workspace', - mcpServers: [], - }); - expect(result.sessionId).toBe('test-session-123'); + expect(result.sessionId).toBeDefined(); + expect(result.processId).toBe(thread.threadId); expect(result.status).toBe('ready'); }); + }); + + // ----------------------------------------------------------------------- + // loadSession + // ----------------------------------------------------------------------- + + describe('loadSession()', () => { + it('should return directly if session already exists in mapping', async () => { + const { service, thread } = createServiceWithAutoEvents(); + + setTimeout(() => { + thread._fireEvent({ + type: 'session_notification', + notification: { + sessionId: 'session-1', + update: { sessionUpdate: 'available_commands_update', availableCommands: [] }, + }, + }); + }, 10); + + const createResult = await service.createSession(mockAgentProcessConfig); + const loadResult = await service.loadSession(createResult.sessionId, mockAgentProcessConfig); + + expect(loadResult.sessionId).toBe(createResult.sessionId); + expect(thread.loadSession).not.toHaveBeenCalled(); + }); - it('should return cached sessionInfo if already initialized', async () => { - const service = createService(); - const first = await service.initializeAgent(mockAgentProcessConfig); - const second = await service.initializeAgent(mockAgentProcessConfig); + it('should create new thread and load session when no idle thread', async () => { + const thread = createMockThread({ + initialized: true, + getStatus: jest.fn().mockReturnValue('idle'), + onEvent: jest.fn(() => ({ dispose: jest.fn() })), + }); + const mockFactory = jest.fn().mockReturnValue(thread); + const service = setupServiceWithMockFactory(mockFactory); + + const result = await service.loadSession('existing-session-id', mockAgentProcessConfig); - expect(first).toBe(second); - expect(mockProcessManager.startAgent).toHaveBeenCalledTimes(1); - expect(mockCliClientService.newSession).toHaveBeenCalledTimes(1); + expect(result.sessionId).toBe('existing-session-id'); + expect(thread.loadSession).toHaveBeenCalledWith(expect.objectContaining({ sessionId: 'existing-session-id' })); + }); + + it('should throw when pool is full and no idle thread', async () => { + const { service } = createServiceWithAutoEvents(); + + // Fill the pool + for (let i = 0; i < 10; i++) { + const t = createMockThread({ + getStatus: jest.fn().mockReturnValue('working'), + onEvent: jest.fn((cb: any) => { + setTimeout(() => { + cb({ + type: 'session_notification', + notification: { + sessionId: `session-${i}`, + update: { sessionUpdate: 'available_commands_update', availableCommands: [] }, + }, + }); + }, 10); + return { dispose: jest.fn() }; + }), + }); + (service as any).threadFactory.mockReturnValueOnce(t); + await service.createSession(mockAgentProcessConfig); + } + + await expect(service.loadSession('new-session', mockAgentProcessConfig)).rejects.toThrow('Thread pool is full'); }); }); + // ----------------------------------------------------------------------- + // sendMessage + // ----------------------------------------------------------------------- + describe('sendMessage()', () => { - it('should return stream with error if not initialized', () => { - const service = createService(); - const stream = service.sendMessage({ prompt: 'hello', sessionId: 'sess-1' }); + it('should return stream with error if session not found', () => { + const { service } = createService(); + const stream = service.sendMessage({ prompt: 'hello', sessionId: 'nonexistent' }, mockAgentProcessConfig); const errors: Error[] = []; stream.onError((e) => errors.push(e)); expect(errors.length).toBe(1); - expect(errors[0].message).toBe('Agent process not initialized'); + expect(errors[0].message).toContain('No active session'); }); - it('should build prompt blocks with text and send prompt', async () => { - const service = createService(); - await service.initializeAgent(mockAgentProcessConfig); + it('should add user message and prompt the thread', async () => { + const { service, thread } = createServiceWithAutoEvents(); - service.sendMessage({ prompt: 'Hello world', sessionId: 'test-session-123' }); + setTimeout(() => { + thread._fireEvent({ + type: 'session_notification', + notification: { + sessionId: 'session-1', + update: { sessionUpdate: 'available_commands_update', availableCommands: [] }, + }, + }); + }, 10); - expect(mockCliClientService.prompt).toHaveBeenCalledWith({ - sessionId: 'test-session-123', - prompt: [{ type: 'text', text: 'Hello world' }], - }); + const createResult = await service.createSession(mockAgentProcessConfig); + service.sendMessage({ prompt: 'Hello world', sessionId: createResult.sessionId }, mockAgentProcessConfig); + + expect(thread.addUserMessage).toHaveBeenCalledWith('Hello world'); + expect(thread.prompt).toHaveBeenCalled(); }); - it('should handle agent_thought_chunk as thought', async () => { - const service = createService(); - await service.initializeAgent(mockAgentProcessConfig); + it('should emit thought updates from session_notification events', async () => { + const { service, thread } = createServiceWithAutoEvents(); - let notificationHandler: any; - mockCliClientService.onNotification.mockImplementation((handler: any) => { - notificationHandler = handler; - return jest.fn(); - }); + setTimeout(() => { + thread._fireEvent({ + type: 'session_notification', + notification: { + sessionId: 'session-1', + update: { sessionUpdate: 'available_commands_update', availableCommands: [] }, + }, + }); + }, 10); + + const createResult = await service.createSession(mockAgentProcessConfig); const updates: any[] = []; - const stream = service.sendMessage({ prompt: 'Hello', sessionId: 'test-session-123' }); + const stream = service.sendMessage( + { prompt: 'Hello', sessionId: createResult.sessionId }, + mockAgentProcessConfig, + ); stream.onData((data) => updates.push(data)); - notificationHandler({ - sessionId: 'test-session-123', - update: { - sessionUpdate: 'agent_thought_chunk', - content: { type: 'text', text: 'I am thinking...' }, + // Simulate a session notification event + thread._fireEvent({ + type: 'session_notification', + notification: { + sessionId: createResult.sessionId, + update: { + sessionUpdate: 'agent_thought_chunk', + content: { type: 'text', text: 'I am thinking...' }, + }, }, }); expect(updates).toContainEqual({ type: 'thought', content: 'I am thinking...' }); }); - it('should handle agent_message_chunk as message', async () => { - const service = createService(); - await service.initializeAgent(mockAgentProcessConfig); + it('should emit message updates from session_notification events', async () => { + const { service, thread } = createServiceWithAutoEvents(); - let notificationHandler: any; - mockCliClientService.onNotification.mockImplementation((handler: any) => { - notificationHandler = handler; - return jest.fn(); - }); + setTimeout(() => { + thread._fireEvent({ + type: 'session_notification', + notification: { + sessionId: 'session-1', + update: { sessionUpdate: 'available_commands_update', availableCommands: [] }, + }, + }); + }, 10); + + const createResult = await service.createSession(mockAgentProcessConfig); const updates: any[] = []; - const stream = service.sendMessage({ prompt: 'Hello', sessionId: 'test-session-123' }); + const stream = service.sendMessage( + { prompt: 'Hello', sessionId: createResult.sessionId }, + mockAgentProcessConfig, + ); stream.onData((data) => updates.push(data)); - notificationHandler({ - sessionId: 'test-session-123', - update: { - sessionUpdate: 'agent_message_chunk', - content: { type: 'text', text: 'Here is my answer.' }, + thread._fireEvent({ + type: 'session_notification', + notification: { + sessionId: createResult.sessionId, + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'Here is my answer.' }, + }, }, }); expect(updates).toContainEqual({ type: 'message', content: 'Here is my answer.' }); }); - it('should handle tool_call notifications', async () => { - const service = createService(); - await service.initializeAgent(mockAgentProcessConfig); + it('should emit tool_call updates', async () => { + const { service, thread } = createServiceWithAutoEvents(); - let notificationHandler: any; - mockCliClientService.onNotification.mockImplementation((handler: any) => { - notificationHandler = handler; - return jest.fn(); - }); + setTimeout(() => { + thread._fireEvent({ + type: 'session_notification', + notification: { + sessionId: 'session-1', + update: { sessionUpdate: 'available_commands_update', availableCommands: [] }, + }, + }); + }, 10); + + const createResult = await service.createSession(mockAgentProcessConfig); const updates: any[] = []; - const stream = service.sendMessage({ prompt: 'Hello', sessionId: 'test-session-123' }); + const stream = service.sendMessage( + { prompt: 'Hello', sessionId: createResult.sessionId }, + mockAgentProcessConfig, + ); stream.onData((data) => updates.push(data)); - notificationHandler({ - sessionId: 'test-session-123', - update: { - sessionUpdate: 'tool_call', - title: 'ReadFile', - rawInput: { path: '/test/file.ts' }, + thread._fireEvent({ + type: 'session_notification', + notification: { + sessionId: createResult.sessionId, + update: { + sessionUpdate: 'tool_call', + title: 'ReadFile', + rawInput: { path: '/test/file.ts' }, + }, }, }); @@ -238,232 +495,548 @@ describe('AcpAgentService', () => { }); }); - it('should handle tool_call_update with diff as tool_result', async () => { - const service = createService(); - await service.initializeAgent(mockAgentProcessConfig); + it('should emit tool_result updates from tool_call_update with diff', async () => { + const { service, thread } = createServiceWithAutoEvents(); - let notificationHandler: any; - mockCliClientService.onNotification.mockImplementation((handler: any) => { - notificationHandler = handler; - return jest.fn(); - }); + setTimeout(() => { + thread._fireEvent({ + type: 'session_notification', + notification: { + sessionId: 'session-1', + update: { sessionUpdate: 'available_commands_update', availableCommands: [] }, + }, + }); + }, 10); + + const createResult = await service.createSession(mockAgentProcessConfig); const updates: any[] = []; - const stream = service.sendMessage({ prompt: 'Hello', sessionId: 'test-session-123' }); + const stream = service.sendMessage( + { prompt: 'Hello', sessionId: createResult.sessionId }, + mockAgentProcessConfig, + ); stream.onData((data) => updates.push(data)); - notificationHandler({ - sessionId: 'test-session-123', - update: { - sessionUpdate: 'tool_call_update', - content: [{ type: 'diff', path: 'src/index.ts' }], + thread._fireEvent({ + type: 'session_notification', + notification: { + sessionId: createResult.sessionId, + update: { + sessionUpdate: 'tool_call_update', + content: [{ type: 'diff', path: 'src/index.ts' }], + }, }, }); expect(updates).toContainEqual({ type: 'tool_result', content: 'Modified src/index.ts' }); }); - it('should filter notifications by sessionId', async () => { - const service = createService(); - await service.initializeAgent(mockAgentProcessConfig); + it('should emit done and end stream after prompt completes', (done) => { + const { service, thread } = createServiceWithAutoEvents(); + + setTimeout(() => { + thread._fireEvent({ + type: 'session_notification', + notification: { + sessionId: 'session-1', + update: { sessionUpdate: 'available_commands_update', availableCommands: [] }, + }, + }); + }, 10); + + service.createSession(mockAgentProcessConfig).then((createResult) => { + const updates: any[] = []; + const stream = service.sendMessage( + { prompt: 'Hello', sessionId: createResult.sessionId }, + mockAgentProcessConfig, + ); + stream.onData((data) => updates.push(data)); + stream.onEnd(() => { + expect(updates).toContainEqual({ type: 'done', content: '' }); + expect(thread.markAssistantComplete).toHaveBeenCalled(); + done(); + }); + }); + }); - let notificationHandler: any; - mockCliClientService.onNotification.mockImplementation((handler: any) => { - notificationHandler = handler; - return jest.fn(); + it('should emit error if prompt fails', async () => { + const eventListeners: Array<(event: any) => void> = []; + const thread = createMockThread({ + onEvent: jest.fn((cb: any) => { + eventListeners.push(cb); + return { dispose: jest.fn() }; + }), + _fireEvent(event: any) { + eventListeners.forEach((cb) => cb(event)); + }, + _eventListeners: eventListeners, + prompt: jest.fn().mockRejectedValue(new Error('Prompt failed')), }); + const mockFactory = jest.fn().mockReturnValue(thread); + const service = setupServiceWithMockFactory(mockFactory); - const updates: any[] = []; - const stream = service.sendMessage({ prompt: 'Hello', sessionId: 'test-session-123' }); - stream.onData((data) => updates.push(data)); + setTimeout(() => { + thread._fireEvent({ + type: 'session_notification', + notification: { + sessionId: 'session-1', + update: { sessionUpdate: 'available_commands_update', availableCommands: [] }, + }, + }); + }, 10); + + const createResult = await service.createSession(mockAgentProcessConfig); + + const errors: Error[] = []; + const stream = service.sendMessage( + { prompt: 'Hello', sessionId: createResult.sessionId }, + mockAgentProcessConfig, + ); + stream.onError((e) => errors.push(e)); - notificationHandler({ - sessionId: 'other-session', - update: { - sessionUpdate: 'agent_message_chunk', - content: { type: 'text', text: 'Should be ignored' }, + // Wait for the async prompt to complete and error to be emitted + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(errors.length).toBe(1); + expect(errors[0].message).toBe('Prompt failed'); + }); + + it('should preserve message from JSON-RPC error objects when prompt fails', async () => { + const eventListeners: Array<(event: any) => void> = []; + const thread = createMockThread({ + onEvent: jest.fn((cb: any) => { + eventListeners.push(cb); + return { dispose: jest.fn() }; + }), + _fireEvent(event: any) { + eventListeners.forEach((cb) => cb(event)); }, + _eventListeners: eventListeners, + prompt: jest.fn().mockRejectedValue({ + code: -32603, + message: 'Internal error: API Error: 422 provider config not found', + data: { errorKind: 'unknown' }, + }), }); + const mockFactory = jest.fn().mockReturnValue(thread); + const service = setupServiceWithMockFactory(mockFactory); + + setTimeout(() => { + thread._fireEvent({ + type: 'session_notification', + notification: { + sessionId: 'session-1', + update: { sessionUpdate: 'available_commands_update', availableCommands: [] }, + }, + }); + }, 10); + + const createResult = await service.createSession(mockAgentProcessConfig); - expect(updates).not.toContainEqual({ type: 'message', content: 'Should be ignored' }); + const errors: Error[] = []; + const stream = service.sendMessage( + { prompt: 'Hello', sessionId: createResult.sessionId }, + mockAgentProcessConfig, + ); + stream.onError((e) => errors.push(e)); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(errors.length).toBe(1); + expect(errors[0].message).toBe('Internal error: API Error: 422 provider config not found'); + expect((errors[0] as any).code).toBe(-32603); + expect((errors[0] as any).data).toEqual({ errorKind: 'unknown' }); }); - it('should include images in prompt blocks', async () => { - const service = createService(); - await service.initializeAgent(mockAgentProcessConfig); + it('should include images in prompt', async () => { + const { service, thread } = createServiceWithAutoEvents(); + + setTimeout(() => { + thread._fireEvent({ + type: 'session_notification', + notification: { + sessionId: 'session-1', + update: { sessionUpdate: 'available_commands_update', availableCommands: [] }, + }, + }); + }, 10); + + const createResult = await service.createSession(mockAgentProcessConfig); const imageData = 'data:image/png;base64,iVBORw0KGgo='; - service.sendMessage({ prompt: 'Look at this', sessionId: 'test-session-123', images: [imageData] }); - - expect(mockCliClientService.prompt).toHaveBeenCalledWith({ - sessionId: 'test-session-123', - prompt: [ - { type: 'text', text: 'Look at this' }, - { type: 'image', data: 'iVBORw0KGgo=', mimeType: 'image/png' }, - ], - }); + service.sendMessage( + { prompt: 'Look at this', sessionId: createResult.sessionId, images: [imageData] }, + mockAgentProcessConfig, + ); + + expect(thread.prompt).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: expect.arrayContaining([ + { type: 'text', text: 'Look at this' }, + { type: 'image', data: 'iVBORw0KGgo=', mimeType: 'image/png' }, + ]), + }), + ); }); }); - describe('cancelRequest()', () => { - it('should call clientService.cancel', async () => { - const service = createService(); - await service.initializeAgent(mockAgentProcessConfig); - - await service.cancelRequest('test-session-123'); + // ----------------------------------------------------------------------- + // cancelRequest + // ----------------------------------------------------------------------- - expect(mockCliClientService.cancel).toHaveBeenCalledWith({ sessionId: 'test-session-123' }); + describe('cancelRequest()', () => { + it('should call thread.cancel', async () => { + const { service, thread } = createServiceWithAutoEvents(); + + setTimeout(() => { + thread._fireEvent({ + type: 'session_notification', + notification: { + sessionId: 'session-1', + update: { sessionUpdate: 'available_commands_update', availableCommands: [] }, + }, + }); + }, 10); + + const result = await service.createSession(mockAgentProcessConfig); + await service.cancelRequest(result.sessionId); + + expect(thread.cancel).toHaveBeenCalledWith(expect.objectContaining({ sessionId: result.sessionId })); }); - it('should return early if process not initialized', async () => { - const service = createService(); - await service.cancelRequest('test-session-123'); + it('should return early and warn if session not found', async () => { + const { service } = createService(); + await service.cancelRequest('nonexistent-session'); - expect(mockCliClientService.cancel).not.toHaveBeenCalled(); expect(mockLogger.warn).toHaveBeenCalled(); }); it('should swallow errors', async () => { - const service = createService(); - await service.initializeAgent(mockAgentProcessConfig); + const { service, thread } = createServiceWithAutoEvents(); + + thread.cancel = jest.fn().mockRejectedValue(new Error('Cancel failed')); + + setTimeout(() => { + thread._fireEvent({ + type: 'session_notification', + notification: { + sessionId: 'session-1', + update: { sessionUpdate: 'available_commands_update', availableCommands: [] }, + }, + }); + }, 10); + + const result = await service.createSession(mockAgentProcessConfig); + await expect(service.cancelRequest(result.sessionId)).resolves.toBeUndefined(); + expect(mockLogger.warn).toHaveBeenCalled(); + }); + }); - mockCliClientService.cancel.mockRejectedValue(new Error('Cancel failed')); + // ----------------------------------------------------------------------- + // disposeSession + // ----------------------------------------------------------------------- - await expect(service.cancelRequest('test-session-123')).resolves.toBeUndefined(); + describe('disposeSession()', () => { + it('should release terminals and remove from session mapping (default)', async () => { + const { service, thread } = createServiceWithAutoEvents(); + + setTimeout(() => { + thread._fireEvent({ + type: 'session_notification', + notification: { + sessionId: 'session-1', + update: { sessionUpdate: 'available_commands_update', availableCommands: [] }, + }, + }); + }, 10); + + const result = await service.createSession(mockAgentProcessConfig); + await service.disposeSession(result.sessionId); + + expect(mockTerminalHandler.releaseSessionTerminals).toHaveBeenCalledWith(result.sessionId); + expect(service.getSessionInfo(result.sessionId)).toBeNull(); + expect(thread.dispose).not.toHaveBeenCalled(); + }); + + it('should fully dispose thread when force=true', async () => { + const { service, thread } = createServiceWithAutoEvents(); + + setTimeout(() => { + thread._fireEvent({ + type: 'session_notification', + notification: { + sessionId: 'session-1', + update: { sessionUpdate: 'available_commands_update', availableCommands: [] }, + }, + }); + }, 10); + + const result = await service.createSession(mockAgentProcessConfig); + await service.disposeSession(result.sessionId, true); + + expect(thread.dispose).toHaveBeenCalled(); + expect(service.getSessionInfo(result.sessionId)).toBeNull(); }); }); + // ----------------------------------------------------------------------- + // stopAgent + // ----------------------------------------------------------------------- + describe('stopAgent()', () => { - it('should stop process, close client, and clear state', async () => { - const service = createService(); - await service.initializeAgent(mockAgentProcessConfig); + it('should dispose all threads and clear pool', async () => { + const { service } = createServiceWithAutoEvents(); + + const threads: MockThread[] = []; + for (let i = 0; i < 3; i++) { + const t = createMockThread({ + onEvent: jest.fn((cb: any) => { + setTimeout(() => { + cb({ + type: 'session_notification', + notification: { + sessionId: `session-${i}`, + update: { sessionUpdate: 'available_commands_update', availableCommands: [] }, + }, + }); + }, 10); + return { dispose: jest.fn() }; + }), + }); + threads.push(t); + (service as any).threadFactory.mockReturnValueOnce(t); + await service.createSession(mockAgentProcessConfig); + } await service.stopAgent(); - expect(mockProcessManager.stopAgent).toHaveBeenCalled(); - expect(mockCliClientService.close).toHaveBeenCalled(); - expect(service.getSessionInfo()).toBeNull(); + for (const t of threads) { + expect(t.dispose).toHaveBeenCalled(); + } + expect((service as any).threadPool).toHaveLength(0); + expect((service as any).sessions.size).toBe(0); }); - it('should be no-op if process not initialized', async () => { - const service = createService(); + it('should be no-op when no threads', async () => { + const { service } = createService(); await service.stopAgent(); - expect(mockProcessManager.stopAgent).not.toHaveBeenCalled(); - expect(mockCliClientService.close).not.toHaveBeenCalled(); + expect((service as any).threadPool).toHaveLength(0); }); }); - describe('dispose()', () => { - it('should unsubscribe disconnect handler, stop handler, and kill agents', async () => { - const service = createService(); - await service.initializeAgent(mockAgentProcessConfig); + // ----------------------------------------------------------------------- + // dispose + // ----------------------------------------------------------------------- + describe('dispose()', () => { + it('should call stopAgent and clean up', async () => { + const { service, thread } = createServiceWithAutoEvents(); + + setTimeout(() => { + thread._fireEvent({ + type: 'session_notification', + notification: { + sessionId: 'session-1', + update: { sessionUpdate: 'available_commands_update', availableCommands: [] }, + }, + }); + }, 10); + + await service.createSession(mockAgentProcessConfig); await service.dispose(); - expect(mockProcessManager.killAllAgents).toHaveBeenCalled(); - expect(service.getSessionInfo()).toBeNull(); + expect(thread.dispose).toHaveBeenCalled(); }); + }); - it('should be no-op when called twice', async () => { - const service = createService(); - await service.initializeAgent(mockAgentProcessConfig); + // ----------------------------------------------------------------------- + // getSessionInfo + // ----------------------------------------------------------------------- - await service.dispose(); - await service.dispose(); + describe('getSessionInfo()', () => { + it('should return null initially (no sessionId)', () => { + const { service } = createService(); + expect(service.getSessionInfo()).toBeNull(); + }); - expect(mockProcessManager.stopAgent).toHaveBeenCalledTimes(1); + it('should return null for unknown sessionId', () => { + const { service } = createService(); + expect(service.getSessionInfo('unknown')).toBeNull(); }); - }); - describe('loadSession()', () => { - it('should set sessionInfo after loading', async () => { - const service = createService(); + it('should return session info for active session', async () => { + const { service, thread } = createServiceWithAutoEvents(); - mockCliClientService.onNotification.mockReturnValue(jest.fn()); + setTimeout(() => { + thread._fireEvent({ + type: 'session_notification', + notification: { + sessionId: 'session-1', + update: { sessionUpdate: 'available_commands_update', availableCommands: [] }, + }, + }); + }, 10); - await service.loadSession('sess-1', mockAgentProcessConfig); + const result = await service.createSession(mockAgentProcessConfig); + const info = service.getSessionInfo(result.sessionId); - const info = service.getSessionInfo(); expect(info).not.toBeNull(); - expect(info?.sessionId).toBe('sess-1'); + expect(info?.sessionId).toBe(result.sessionId); + expect(info?.processId).toBe(thread.threadId); + expect(info?.status).toBe('ready'); }); }); - describe('listSessions()', () => { - it('should delegate to clientService.listSessions', async () => { - const service = createService(); - const expected = { - sessions: [{ sessionId: 's1', cwd: '/test', title: 'Session 1' }], - nextCursor: 'cursor-2', - }; - mockCliClientService.listSessions.mockResolvedValue(expected); - - const result = await service.listSessions({ cwd: '/test' }); + // ----------------------------------------------------------------------- + // listSessions + // ----------------------------------------------------------------------- - expect(result).toEqual(expected); + describe('listSessions()', () => { + it('should return all active sessions', async () => { + const { service } = createServiceWithAutoEvents(); + + for (let i = 0; i < 2; i++) { + const t = createMockThread({ + onEvent: jest.fn((cb: any) => { + setTimeout(() => { + cb({ + type: 'session_notification', + notification: { + sessionId: `session-${i}`, + update: { sessionUpdate: 'available_commands_update', availableCommands: [] }, + }, + }); + }, 10); + return { dispose: jest.fn() }; + }), + }); + (service as any).threadFactory.mockReturnValueOnce(t); + await service.createSession(mockAgentProcessConfig); + } + + const result = await service.listSessions(); + + expect(result.sessions).toHaveLength(2); + expect(result.nextCursor).toBeUndefined(); }); }); - describe('setSessionMode()', () => { - it('should delegate to clientService.setSessionMode', async () => { - const service = createService(); + // ----------------------------------------------------------------------- + // setSessionMode + // ----------------------------------------------------------------------- - await service.setSessionMode({ sessionId: 'sess-1', modeId: 'code' }); + describe('setSessionMode()', () => { + it('should log but not throw for existing session', async () => { + const { service, thread } = createServiceWithAutoEvents(); + + setTimeout(() => { + thread._fireEvent({ + type: 'session_notification', + notification: { + sessionId: 'session-1', + update: { sessionUpdate: 'available_commands_update', availableCommands: [] }, + }, + }); + }, 10); + + const result = await service.createSession(mockAgentProcessConfig); + await service.setSessionMode({ sessionId: result.sessionId, modeId: 'code' }); + + expect(mockLogger.log).toHaveBeenCalled(); + }); - expect(mockCliClientService.setSessionMode).toHaveBeenCalledWith({ sessionId: 'sess-1', modeId: 'code' }); + it('should throw if session not found', async () => { + const { service } = createService(); + await expect(service.setSessionMode({ sessionId: 'nonexistent', modeId: 'code' })).rejects.toThrow( + 'No active session', + ); }); }); - describe('disposeSession()', () => { - it('should call terminalHandler.releaseSessionTerminals', async () => { - const service = createService(); - - await service.disposeSession('sess-1'); + // ----------------------------------------------------------------------- + // getAvailableModes + // ----------------------------------------------------------------------- - expect(mockTerminalHandler.releaseSessionTerminals).toHaveBeenCalledWith('sess-1'); + describe('getAvailableModes()', () => { + it('should return null (not implemented yet)', async () => { + const { service } = createService(); + const result = await service.getAvailableModes(); + expect(result).toBeNull(); }); }); - describe('getAvailableModes()', () => { - it('should delegate to clientService.getSessionModes', async () => { - const service = createService(); - const expected = { availableModes: [{ id: 'code', name: 'Code' }], defaultModeId: 'code' }; - mockCliClientService.getSessionModes.mockResolvedValue(expected); - - const result = await service.getAvailableModes(); + // ----------------------------------------------------------------------- + // Thread pool semantics + // ----------------------------------------------------------------------- + + describe('Thread pool semantics', () => { + it('should reuse idle threads for new sessions', async () => { + const { service, mockFactory, thread } = createServiceWithAutoEvents(); + + // After first session, mark thread as needing reset (simulating bound session) + thread.needsReset = true; + + setTimeout(() => { + thread._fireEvent({ + type: 'session_notification', + notification: { + sessionId: 'session-1', + update: { sessionUpdate: 'available_commands_update', availableCommands: [] }, + }, + }); + }, 10); + + // Create first session + const result1 = await service.createSession(mockAgentProcessConfig); + expect(mockFactory).toHaveBeenCalledTimes(1); + + // Dispose session (thread returns to pool as idle, but still needsReset=true) + await service.disposeSession(result1.sessionId); + + // Reset the mock factory for next call tracking + mockFactory.mockClear(); + mockFactory.mockReturnValue(thread); // Return same thread + + // Create second session - should reuse idle thread + setTimeout(() => { + thread._fireEvent({ + type: 'session_notification', + notification: { + sessionId: 'session-2', + update: { sessionUpdate: 'available_commands_update', availableCommands: [] }, + }, + }); + }, 10); + + const result2 = await service.createSession(mockAgentProcessConfig); + expect(mockFactory).toHaveBeenCalledTimes(0); // No new thread created + + // The thread should have been reset (needsReset was true, so reset was called) + expect(thread.reset).toHaveBeenCalled(); + }); - expect(result).toEqual(expected); + it('should track maxPoolSize correctly', async () => { + const { service } = createService(); + expect((service as any).maxPoolSize).toBe(10); }); }); + // ----------------------------------------------------------------------- + // parseDataUrl + // ----------------------------------------------------------------------- + describe('parseDataUrl()', () => { it('should extract mimeType and base64Data from data URLs', () => { - const service = createService(); + const { service } = createService(); const result = (service as any).parseDataUrl('data:image/png;base64,helloWorld'); expect(result).toEqual({ mimeType: 'image/png', base64Data: 'helloWorld' }); }); it('should return default mimeType for non-data URLs', () => { - const service = createService(); + const { service } = createService(); const result = (service as any).parseDataUrl('not-a-data-url'); expect(result).toEqual({ mimeType: 'image/jpeg', base64Data: 'not-a-data-url' }); }); }); - - describe('disconnect handling', () => { - it('should clear state on disconnect', async () => { - const service = createService(); - await service.initializeAgent(mockAgentProcessConfig); - - const onDisconnectCall = (mockCliClientService.onDisconnect as any).mock.calls[0]; - const disconnectHandler = onDisconnectCall[0]; - - disconnectHandler(); - - expect(service.getSessionInfo()).toBeNull(); - expect(service['currentProcessId']).toBeNull(); - expect(mockLogger.warn).toHaveBeenCalledWith('[AcpAgentService] Connection lost, clearing state'); - }); - }); }); diff --git a/packages/ai-native/__test__/node/acp-cli-back.test.ts b/packages/ai-native/__test__/node/acp-cli-back.test.ts index 67c9d291de..7bbc9d72bf 100644 --- a/packages/ai-native/__test__/node/acp-cli-back.test.ts +++ b/packages/ai-native/__test__/node/acp-cli-back.test.ts @@ -4,6 +4,7 @@ import { SumiReadableStream } from '@opensumi/ide-utils/lib/stream'; import { AgentSessionInfo, AgentUpdate, IAcpAgentService } from '../../src/node/acp/acp-agent.service'; import { AcpCliBackService } from '../../src/node/acp/acp-cli-back.service'; +import { AcpThreadStatusCallerService } from '../../src/node/acp/acp-thread-status-caller.service'; import { OpenAICompatibleModel } from '../../src/node/openai-compatible/openai-compatible-language-model'; // Mock dependencies @@ -20,9 +21,10 @@ describe('AcpCliBackService', () => { let mockOpenAIModel: jest.Mocked; const mockAgentSessionConfig: AgentProcessConfig = { + agentId: 'test-agent', command: 'npx', args: ['@anthropic-ai/claude-code@latest'], - workspaceDir: '/test/workspace', + cwd: '/test/workspace', }; const mockSessionInfo: AgentSessionInfo = { @@ -35,6 +37,8 @@ describe('AcpCliBackService', () => { beforeEach(() => { jest.clearAllMocks(); + const mockOnThreadStatusChange = new Emitter<{ sessionId: string; status: string }>(); + mockAgentService = { createSession: jest.fn(), initializeAgent: jest.fn(), @@ -48,6 +52,7 @@ describe('AcpCliBackService', () => { setSessionMode: jest.fn(), stopAgent: jest.fn(), getAvailableModes: jest.fn(), + onThreadStatusChange: mockOnThreadStatusChange.event, } as unknown as jest.Mocked; mockLogger = { @@ -70,6 +75,10 @@ describe('AcpCliBackService', () => { Object.defineProperty(service, 'agentService', { value: mockAgentService, writable: true }); Object.defineProperty(service, 'logger', { value: mockLogger, writable: true }); Object.defineProperty(service, 'openAICompatibleModel', { value: mockOpenAIModel, writable: true }); + Object.defineProperty(service, 'threadStatusCaller', { + value: { notifyThreadStatusChange: jest.fn() }, + writable: true, + }); }); describe('ready()', () => { @@ -97,26 +106,6 @@ describe('AcpCliBackService', () => { expect(result).toEqual(expected); expect(mockAgentService.createSession).toHaveBeenCalledWith(mockAgentSessionConfig); }); - - it('should ensure agent initialized before creating session', async () => { - mockAgentService.getSessionInfo.mockReturnValue(mockSessionInfo); - mockAgentService.createSession.mockResolvedValue({ sessionId: 's1', availableCommands: [] }); - - await service.createSession(mockAgentSessionConfig); - - expect(mockAgentService.getSessionInfo).toHaveBeenCalled(); - expect(mockAgentService.initializeAgent).not.toHaveBeenCalled(); - }); - - it('should initialize agent when no existing session', async () => { - mockAgentService.getSessionInfo.mockReturnValue(null); - mockAgentService.initializeAgent.mockResolvedValue(mockSessionInfo); - mockAgentService.createSession.mockResolvedValue({ sessionId: 's1', availableCommands: [] }); - - await service.createSession(mockAgentSessionConfig); - - expect(mockAgentService.initializeAgent).toHaveBeenCalledWith(mockAgentSessionConfig); - }); }); describe('requestStream() - fallback to OpenAI', () => { @@ -135,20 +124,18 @@ describe('AcpCliBackService', () => { describe('requestStream() - agent mode', () => { it('should use agent stream when agentSessionConfig is provided', async () => { - mockAgentService.getSessionInfo.mockReturnValue(mockSessionInfo); - + mockAgentService.createSession.mockResolvedValue({ sessionId: 'new-session', availableCommands: [] }); const agentStream = new SumiReadableStream(); mockAgentService.sendMessage.mockReturnValue(agentStream); const stream = await service.requestStream('prompt', { agentSessionConfig: mockAgentSessionConfig }); expect(stream).toBeInstanceOf(SumiReadableStream); - expect(mockAgentService.getSessionInfo).toHaveBeenCalled(); + expect(mockAgentService.createSession).toHaveBeenCalledWith(mockAgentSessionConfig); }); it('should forward agent updates to the output stream', async () => { - mockAgentService.getSessionInfo.mockReturnValue(mockSessionInfo); - + mockAgentService.createSession.mockResolvedValue({ sessionId: 'new-session', availableCommands: [] }); const agentStream = new SumiReadableStream(); mockAgentService.sendMessage.mockReturnValue(agentStream); @@ -168,8 +155,7 @@ describe('AcpCliBackService', () => { }); it('should emit error when agent stream fails', async () => { - mockAgentService.getSessionInfo.mockReturnValue(mockSessionInfo); - + mockAgentService.createSession.mockResolvedValue({ sessionId: 'new-session', availableCommands: [] }); const agentStream = new SumiReadableStream(); mockAgentService.sendMessage.mockReturnValue(agentStream); @@ -184,9 +170,30 @@ describe('AcpCliBackService', () => { expect(receivedError[0].message).toBe('Agent connection lost'); }); - it('should handle cancellation token', async () => { - mockAgentService.getSessionInfo.mockReturnValue(mockSessionInfo); + it('should preserve message from agent stream error objects', async () => { + mockAgentService.createSession.mockResolvedValue({ sessionId: 'new-session', availableCommands: [] }); + const agentStream = new SumiReadableStream(); + mockAgentService.sendMessage.mockReturnValue(agentStream); + + const output = await service.requestStream('prompt', { agentSessionConfig: mockAgentSessionConfig }); + const receivedError: Error[] = []; + output.onError((err) => receivedError.push(err)); + + agentStream.emitError({ + code: -32603, + message: 'Internal error: API Error: 422 provider config not found', + data: { errorKind: 'unknown' }, + } as any); + + expect(receivedError.length).toBe(1); + expect(receivedError[0].message).toBe('Internal error: API Error: 422 provider config not found'); + expect((receivedError[0] as any).code).toBe(-32603); + expect((receivedError[0] as any).data).toEqual({ errorKind: 'unknown' }); + }); + + it('should handle cancellation token', async () => { + mockAgentService.createSession.mockResolvedValue({ sessionId: 'new-session', availableCommands: [] }); const agentStream = new SumiReadableStream(); mockAgentService.sendMessage.mockReturnValue(agentStream); @@ -200,12 +207,10 @@ describe('AcpCliBackService', () => { cancelEmitter.fire(); - expect(mockAgentService.cancelRequest).toHaveBeenCalledWith(mockSessionInfo.sessionId); + expect(mockAgentService.cancelRequest).toHaveBeenCalledWith('new-session'); }); - it('should use provided sessionId from options instead of sessionInfo', async () => { - mockAgentService.getSessionInfo.mockReturnValue(mockSessionInfo); - + it('should use provided sessionId from options instead of creating new session', async () => { const agentStream = new SumiReadableStream(); mockAgentService.sendMessage.mockReturnValue(agentStream); @@ -223,7 +228,7 @@ describe('AcpCliBackService', () => { describe('convertAgentUpdateToChatProgress()', () => { it('should convert "thought" update to reasoning progress', async () => { - mockAgentService.getSessionInfo.mockReturnValue(mockSessionInfo); + mockAgentService.createSession.mockResolvedValue({ sessionId: 'new-session', availableCommands: [] }); const agentStream = new SumiReadableStream(); mockAgentService.sendMessage.mockReturnValue(agentStream); @@ -238,7 +243,7 @@ describe('AcpCliBackService', () => { }); it('should convert "message" update to content progress', async () => { - mockAgentService.getSessionInfo.mockReturnValue(mockSessionInfo); + mockAgentService.createSession.mockResolvedValue({ sessionId: 'new-session', availableCommands: [] }); const agentStream = new SumiReadableStream(); mockAgentService.sendMessage.mockReturnValue(agentStream); @@ -253,7 +258,7 @@ describe('AcpCliBackService', () => { }); it('should convert "tool_result" update to content progress', async () => { - mockAgentService.getSessionInfo.mockReturnValue(mockSessionInfo); + mockAgentService.createSession.mockResolvedValue({ sessionId: 'new-session', availableCommands: [] }); const agentStream = new SumiReadableStream(); mockAgentService.sendMessage.mockReturnValue(agentStream); @@ -267,8 +272,8 @@ describe('AcpCliBackService', () => { expect(receivedData).toEqual([{ kind: 'content', content: 'Modified file.ts' }]); }); - it('should ignore "tool_call" and "done" updates', async () => { - mockAgentService.getSessionInfo.mockReturnValue(mockSessionInfo); + it('should convert "tool_call" update to toolCall progress and ignore "done"', async () => { + mockAgentService.createSession.mockResolvedValue({ sessionId: 'new-session', availableCommands: [] }); const agentStream = new SumiReadableStream(); mockAgentService.sendMessage.mockReturnValue(agentStream); @@ -276,10 +281,23 @@ describe('AcpCliBackService', () => { const receivedData: any[] = []; output.onData((data) => receivedData.push(data)); - agentStream.emitData({ type: 'tool_call', content: 'read_file' }); + agentStream.emitData({ + type: 'tool_call', + content: 'read_file', + toolCall: { toolCallId: 'tc-1', name: 'read_file', input: {} }, + }); agentStream.emitData({ type: 'done', content: '' }); - expect(receivedData).toEqual([]); + expect(receivedData).toEqual([ + { + kind: 'toolCall', + content: { + id: 'tc-1', + type: 'function', + function: { name: 'read_file', arguments: '{}' }, + }, + }, + ]); }); }); @@ -382,39 +400,26 @@ describe('AcpCliBackService', () => { }); describe('listSessions()', () => { - it('should initialize agent and list sessions', async () => { - mockAgentService.getSessionInfo.mockReturnValue(mockSessionInfo); + it('should list sessions via agentService', async () => { mockAgentService.listSessions.mockResolvedValue({ - sessions: [{ sessionId: 's1', cwd: '/test', title: 'Session 1' }], + sessions: [{ sessionId: 's1', cwd: '/test', title: 'Session 1' } as any], nextCursor: 'cursor-2', }); const result = await service.listSessions(mockAgentSessionConfig); - expect(mockAgentService.getSessionInfo).toHaveBeenCalled(); expect(mockAgentService.listSessions).toHaveBeenCalledWith({ - cwd: mockAgentSessionConfig.workspaceDir, + cwd: mockAgentSessionConfig.cwd, }); expect(result.sessions).toHaveLength(1); expect(result.nextCursor).toBe('cursor-2'); }); it('should re-throw error from listSessions', async () => { - mockAgentService.getSessionInfo.mockReturnValue(mockSessionInfo); mockAgentService.listSessions.mockRejectedValue(new Error('List failed')); await expect(service.listSessions(mockAgentSessionConfig)).rejects.toThrow('List failed'); }); - - it('should initialize agent when no existing session', async () => { - mockAgentService.getSessionInfo.mockReturnValue(null); - mockAgentService.initializeAgent.mockResolvedValue(mockSessionInfo); - mockAgentService.listSessions.mockResolvedValue({ sessions: [], nextCursor: undefined }); - - await service.listSessions(mockAgentSessionConfig); - - expect(mockAgentService.initializeAgent).toHaveBeenCalledWith(mockAgentSessionConfig); - }); }); describe('dispose()', () => { @@ -464,8 +469,7 @@ describe('AcpCliBackService', () => { describe('requestStream() - with history and images', () => { it('should forward history to agentService.sendMessage', async () => { - mockAgentService.getSessionInfo.mockReturnValue(mockSessionInfo); - + mockAgentService.createSession.mockResolvedValue({ sessionId: 'new-session', availableCommands: [] }); const agentStream = new SumiReadableStream(); mockAgentService.sendMessage.mockReturnValue(agentStream); @@ -488,8 +492,7 @@ describe('AcpCliBackService', () => { }); it('should handle empty history array', async () => { - mockAgentService.getSessionInfo.mockReturnValue(mockSessionInfo); - + mockAgentService.createSession.mockResolvedValue({ sessionId: 'new-session', availableCommands: [] }); const agentStream = new SumiReadableStream(); mockAgentService.sendMessage.mockReturnValue(agentStream); @@ -505,8 +508,7 @@ describe('AcpCliBackService', () => { }); it('should forward images to agentService.sendMessage', async () => { - mockAgentService.getSessionInfo.mockReturnValue(mockSessionInfo); - + mockAgentService.createSession.mockResolvedValue({ sessionId: 'new-session', availableCommands: [] }); const agentStream = new SumiReadableStream(); mockAgentService.sendMessage.mockReturnValue(agentStream); @@ -525,9 +527,8 @@ describe('AcpCliBackService', () => { }); describe('setupAgentStream error handling', () => { - it('should emit error when ensureAgentInitialized throws', async () => { - mockAgentService.getSessionInfo.mockReturnValue(null); - mockAgentService.initializeAgent.mockRejectedValue(new Error('Init failed')); + it('should emit error when createSession throws', async () => { + mockAgentService.createSession.mockRejectedValue(new Error('Session creation failed')); const stream = await service.requestStream('prompt', { agentSessionConfig: mockAgentSessionConfig, @@ -539,14 +540,13 @@ describe('AcpCliBackService', () => { await new Promise((resolve) => setTimeout(resolve, 50)); expect(errors.length).toBe(1); - expect(errors[0].message).toBe('Init failed'); + expect(errors[0].message).toBe('Session creation failed'); }); }); describe('convertToSimpleMessage helper (indirect)', () => { it('should convert CoreMessage with array content to SimpleMessage', async () => { - mockAgentService.getSessionInfo.mockReturnValue(mockSessionInfo); - + mockAgentService.createSession.mockResolvedValue({ sessionId: 'new-session', availableCommands: [] }); const agentStream = new SumiReadableStream(); mockAgentService.sendMessage.mockReturnValue(agentStream); @@ -574,8 +574,7 @@ describe('AcpCliBackService', () => { }); it('should filter non-text content parts from array content', async () => { - mockAgentService.getSessionInfo.mockReturnValue(mockSessionInfo); - + mockAgentService.createSession.mockResolvedValue({ sessionId: 'new-session', availableCommands: [] }); const agentStream = new SumiReadableStream(); mockAgentService.sendMessage.mockReturnValue(agentStream); @@ -603,4 +602,73 @@ describe('AcpCliBackService', () => { ); }); }); + + describe('thread status subscription', () => { + let mockOnThreadStatusChange: Emitter<{ sessionId: string; status: string }>; + let mockThreadStatusCaller: { notifyThreadStatusChange: jest.Mock }; + + beforeEach(() => { + mockOnThreadStatusChange = new Emitter<{ sessionId: string; status: string }>(); + mockThreadStatusCaller = { notifyThreadStatusChange: jest.fn() }; + + (mockAgentService as any).onThreadStatusChange = mockOnThreadStatusChange.event; + Object.defineProperty(service, 'threadStatusCaller', { value: mockThreadStatusCaller, writable: true }); + }); + + afterEach(() => { + mockOnThreadStatusChange.dispose(); + }); + + it('should subscribe to onThreadStatusChange on first agentRequestStream', async () => { + const stream = new SumiReadableStream(); + const agentStream = new SumiReadableStream(); + mockAgentService.createSession.mockResolvedValue({ sessionId: 'sess-1', availableCommands: [] }); + mockAgentService.sendMessage.mockReturnValue(agentStream); + + await service.requestStream('hello', { + agentSessionConfig: mockAgentSessionConfig, + sessionId: 'sess-1', + }); + + // Fire a thread status event + mockOnThreadStatusChange.fire({ sessionId: 'sess-1', status: 'idle' }); + + expect(mockThreadStatusCaller.notifyThreadStatusChange).toHaveBeenCalledWith('sess-1', 'idle'); + }); + + it('should not create duplicate subscriptions on subsequent calls', async () => { + const agentStream = new SumiReadableStream(); + mockAgentService.sendMessage.mockReturnValue(agentStream); + + await service.requestStream('hello', { + agentSessionConfig: mockAgentSessionConfig, + sessionId: 'sess-1', + }); + + await service.requestStream('hello again', { + agentSessionConfig: mockAgentSessionConfig, + sessionId: 'sess-1', + }); + + // Fire one event — should only be forwarded once + mockOnThreadStatusChange.fire({ sessionId: 'sess-1', status: 'working' }); + + expect(mockThreadStatusCaller.notifyThreadStatusChange).toHaveBeenCalledTimes(1); + }); + + it('should silently skip if threadStatusCaller is unavailable', async () => { + Object.defineProperty(service, 'threadStatusCaller', { value: undefined, writable: true }); + + const agentStream = new SumiReadableStream(); + mockAgentService.sendMessage.mockReturnValue(agentStream); + + await service.requestStream('hello', { + agentSessionConfig: mockAgentSessionConfig, + sessionId: 'sess-1', + }); + + // Should not throw + mockOnThreadStatusChange.fire({ sessionId: 'sess-1', status: 'idle' }); + }); + }); }); diff --git a/packages/ai-native/__test__/node/acp-cli-client.test.ts b/packages/ai-native/__test__/node/acp-cli-client.test.ts deleted file mode 100644 index b9b192217c..0000000000 --- a/packages/ai-native/__test__/node/acp-cli-client.test.ts +++ /dev/null @@ -1,546 +0,0 @@ -jest.mock('@opensumi/di', () => { - const actual = jest.requireActual('@opensumi/di'); - const noopDecorator = () => () => {}; - return { - ...actual, - Injectable: () => (cls: any) => cls, - Autowired: noopDecorator, - Inject: noopDecorator, - Optional: noopDecorator, - }; -}); - -import { EventEmitter } from 'events'; - -import { ACP_PROTOCOL_VERSION, AcpCliClientService } from '../../src/node/acp/acp-cli-client.service'; -import { AcpAgentRequestHandler } from '../../src/node/acp/handlers/agent-request.handler'; - -// Mock dependencies -const mockLogger = { - log: jest.fn(), - error: jest.fn(), - debug: jest.fn(), - verbose: jest.fn(), - warn: jest.fn(), - critical: jest.fn(), - dispose: jest.fn(), - getLevel: jest.fn(), - setLevel: jest.fn(), -}; - -const mockAgentRequestHandler = { - handleReadTextFile: jest.fn(), - handleWriteTextFile: jest.fn(), - handlePermissionRequest: jest.fn(), - handleCreateTerminal: jest.fn(), - handleTerminalOutput: jest.fn(), - handleWaitForTerminalExit: jest.fn(), - handleKillTerminal: jest.fn(), - handleReleaseTerminal: jest.fn(), -}; - -describe('AcpCliClientService', () => { - let service: AcpCliClientService; - let mockStdin: any; - let mockStdout: any; - - beforeEach(() => { - jest.clearAllMocks(); - - mockStdin = new EventEmitter() as any; - mockStdin.writable = true; - mockStdin.write = jest.fn().mockReturnValue(true); - mockStdin.end = jest.fn(); - - mockStdout = new EventEmitter() as any; - mockStdout.removeAllListeners = jest.fn(); - - service = new AcpCliClientService(); - Object.defineProperty(service, 'logger', { value: mockLogger, writable: true }); - Object.defineProperty(service, 'agentRequestHandler', { value: mockAgentRequestHandler, writable: true }); - }); - - function setTransport() { - service.setTransport(mockStdout, mockStdin); - } - - describe('setTransport()', () => { - it('should set stdin/stdout and transition to connected state', () => { - setTransport(); - expect(service.isConnected()).toBe(true); - }); - - it('should reject pending requests when reconnecting', () => { - setTransport(); - - // Simulate a pending request - (service as any).pendingRequests.set(1, { - resolve: jest.fn(), - reject: jest.fn(), - }); - - // Reconnect - setTransport(); - - expect((service as any).pendingRequests.size).toBe(0); - }); - - it('should clear request queue when reconnecting', () => { - setTransport(); - - (service as any).requestQueue = [{ method: 'test', params: {}, resolve: jest.fn(), reject: jest.fn() }]; - - setTransport(); - - expect((service as any).requestQueue).toEqual([]); - }); - - it('should remove old listeners before attaching new ones', () => { - setTransport(); - // Reset mock count - mockStdout.removeAllListeners.mockClear(); - // Reconnect - this should call removeAllListeners on the OLD stdout - setTransport(); - - expect(mockStdout.removeAllListeners).toHaveBeenCalled(); - }); - - it('should reset protocol and capability state', () => { - setTransport(); - (service as any).negotiatedProtocolVersion = 1; - (service as any).agentCapabilities = { fs: true }; - - setTransport(); - - expect(service.getNegotiatedProtocolVersion()).toBeNull(); - expect(service.getAgentCapabilities()).toBeNull(); - }); - }); - - describe('isConnected()', () => { - it('should return false before transport is set', () => { - expect(service.isConnected()).toBe(false); - }); - - it('should return true after setTransport', () => { - setTransport(); - expect(service.isConnected()).toBe(true); - }); - - it('should return false after close', () => { - setTransport(); - service.close(); - expect(service.isConnected()).toBe(false); - }); - }); - - describe('close()', () => { - it('should clear handlers and streams', () => { - setTransport(); - (service as any).notificationHandlers = [jest.fn()]; - (service as any).disconnectHandlers = [jest.fn()]; - - service.close(); - - expect((service as any).notificationHandlers).toEqual([]); - expect((service as any).disconnectHandlers).toEqual([]); - expect(mockStdout.removeAllListeners).toHaveBeenCalled(); - expect(mockStdin.end).toHaveBeenCalled(); - }); - - it('should not throw when stdin.end fails', () => { - setTransport(); - mockStdin.end.mockImplementation(() => { - throw new Error('already closed'); - }); - - expect(() => service.close()).not.toThrow(); - }); - }); - - describe('handleDisconnect()', () => { - it('should transition to disconnected state', () => { - setTransport(); - service.handleDisconnect(); - expect(service.isConnected()).toBe(false); - }); - - it('should reject all pending requests', () => { - setTransport(); - const reject = jest.fn(); - (service as any).pendingRequests.set(1, { resolve: jest.fn(), reject }); - (service as any).pendingRequests.set(2, { resolve: jest.fn(), reject }); - - service.handleDisconnect(); - - expect(reject).toHaveBeenCalledTimes(2); - expect(reject).toHaveBeenCalledWith(new Error('Not connected to agent process')); - }); - - it('should reject all queued requests', () => { - setTransport(); - const reject = jest.fn(); - (service as any).requestQueue = [{ method: 'test', params: {}, resolve: jest.fn(), reject }]; - - service.handleDisconnect(); - - expect(reject).toHaveBeenCalledWith(new Error('Not connected to agent process')); - }); - - it('should call disconnect handlers', () => { - setTransport(); - const handler = jest.fn(); - service.onDisconnect(handler); - - service.handleDisconnect(); - - expect(handler).toHaveBeenCalled(); - }); - - it('should clear all state', () => { - setTransport(); - (service as any).negotiatedProtocolVersion = 1; - (service as any).agentCapabilities = {}; - (service as any).agentInfo = {}; - (service as any).authMethods = ['oauth']; - (service as any).sessionModes = {}; - - service.handleDisconnect(); - - expect(service.getNegotiatedProtocolVersion()).toBeNull(); - expect(service.getAgentCapabilities()).toBeNull(); - expect(service.getAgentInfo()).toBeNull(); - expect(service.getAuthMethods()).toEqual([]); - expect(service.getSessionModes()).toBeNull(); - }); - - it('should be idempotent - no effect when already disconnected', () => { - setTransport(); - service.handleDisconnect(); - - const handler = jest.fn(); - service.onDisconnect(handler); - service.handleDisconnect(); - - expect(handler).not.toHaveBeenCalled(); - }); - }); - - describe('onDisconnect()', () => { - it('should return unsubscribe function', () => { - setTransport(); - const handler = jest.fn(); - const unsubscribe = service.onDisconnect(handler); - - unsubscribe(); - - service.handleDisconnect(); - expect(handler).not.toHaveBeenCalled(); - }); - }); - - describe('onNotification()', () => { - it('should return unsubscribe function', () => { - const handler = jest.fn(); - const unsubscribe = service.onNotification(handler); - - unsubscribe(); - - expect((service as any).notificationHandlers).not.toContain(handler); - }); - }); - - describe('initialize()', () => { - it('should send initialize request and store protocol version', async () => { - setTransport(); - - const sendRequestSpy = jest.spyOn(service as any, 'sendRequest').mockResolvedValue({ - protocolVersion: ACP_PROTOCOL_VERSION, - agentCapabilities: { fs: true }, - agentInfo: { name: 'test', version: '1.0' }, - }); - - const result = await service.initialize(); - - expect(result.protocolVersion).toBe(ACP_PROTOCOL_VERSION); - expect(service.getNegotiatedProtocolVersion()).toBe(ACP_PROTOCOL_VERSION); - expect(service.getAgentCapabilities()).toEqual({ fs: true }); - expect(service.getAgentInfo()).toEqual({ name: 'test', version: '1.0' }); - sendRequestSpy.mockRestore(); - }); - - it('should throw if protocol version is higher than supported', async () => { - setTransport(); - - jest.spyOn(service as any, 'sendRequest').mockResolvedValue({ - protocolVersion: ACP_PROTOCOL_VERSION + 1, - }); - - jest.spyOn(service as any, 'close').mockResolvedValue(undefined); - - await expect(service.initialize()).rejects.toThrow('Unsupported protocol version'); - }); - - it('should throw if not connected', async () => { - await expect(service.initialize()).rejects.toThrow('Not connected to agent process'); - }); - - it('should accept lower protocol version with warning', async () => { - setTransport(); - - jest.spyOn(service as any, 'sendRequest').mockResolvedValue({ - protocolVersion: ACP_PROTOCOL_VERSION - 1, - }); - - const result = await service.initialize(); - - expect(result.protocolVersion).toBe(ACP_PROTOCOL_VERSION - 1); - expect(mockLogger.warn).toHaveBeenCalled(); - }); - }); - - describe('sendRequest()', () => { - it('should throw if not connected', async () => { - await expect((service as any).sendRequest('test', {})).rejects.toThrow('Not connected to agent process'); - }); - }); - - describe('handleData() - NDJSON parsing', () => { - it('should parse a single JSON-RPC response', () => { - setTransport(); - const resolve = jest.fn(); - (service as any).pendingRequests.set(1, { resolve, reject: jest.fn() }); - - mockStdout.emit('data', Buffer.from('{"jsonrpc":"2.0","id":1,"result":{"ok":true}}\n')); - - expect(resolve).toHaveBeenCalledWith({ ok: true }); - }); - - it('should parse multiple lines in one chunk', () => { - setTransport(); - const resolve1 = jest.fn(); - const resolve2 = jest.fn(); - (service as any).pendingRequests.set(1, { resolve: resolve1, reject: jest.fn() }); - (service as any).pendingRequests.set(2, { resolve: resolve2, reject: jest.fn() }); - - mockStdout.emit( - 'data', - Buffer.from('{"jsonrpc":"2.0","id":1,"result":"a"}\n{"jsonrpc":"2.0","id":2,"result":"b"}\n'), - ); - - expect(resolve1).toHaveBeenCalledWith('a'); - expect(resolve2).toHaveBeenCalledWith('b'); - }); - - it('should handle partial messages across chunks', () => { - setTransport(); - const resolve = jest.fn(); - (service as any).pendingRequests.set(1, { resolve, reject: jest.fn() }); - - // Send partial message - mockStdout.emit('data', Buffer.from('{"jsonrpc":"2.0","id":1,')); - expect(resolve).not.toHaveBeenCalled(); - - // Complete the message - mockStdout.emit('data', Buffer.from('"result":"done"}\n')); - expect(resolve).toHaveBeenCalledWith('done'); - }); - - it('should handle error responses', () => { - setTransport(); - const reject = jest.fn(); - (service as any).pendingRequests.set(1, { resolve: jest.fn(), reject }); - - mockStdout.emit( - 'data', - Buffer.from('{"jsonrpc":"2.0","id":1,"error":{"code":-32600,"message":"Invalid request"}}\n'), - ); - - expect(reject).toHaveBeenCalled(); - const error = reject.mock.calls[0][0]; - expect(error.message).toBe('Invalid request'); - expect((error as any).code).toBe(-32600); - }); - - it('should skip empty lines', () => { - setTransport(); - const resolve = jest.fn(); - (service as any).pendingRequests.set(1, { resolve, reject: jest.fn() }); - - mockStdout.emit('data', Buffer.from('\n\n{"jsonrpc":"2.0","id":1,"result":"ok"}\n\n')); - - expect(resolve).toHaveBeenCalledWith('ok'); - }); - - it('should log error for invalid JSON', () => { - setTransport(); - - mockStdout.emit('data', Buffer.from('not json\n')); - - expect(mockLogger.error).toHaveBeenCalled(); - }); - }); - - describe('handleIncomingNotification()', () => { - it('should dispatch session/update to notification handlers', () => { - setTransport(); - const handler = jest.fn(); - service.onNotification(handler); - - mockStdout.emit( - 'data', - Buffer.from( - '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"s1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"Hello"}}}}\n', - ), - ); - - expect(handler).toHaveBeenCalledWith({ - sessionId: 's1', - update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'Hello' } }, - }); - }); - - it('should update currentModeId on current_mode_update', () => { - setTransport(); - (service as any).sessionModes = { currentModeId: 'old' }; - - mockStdout.emit( - 'data', - Buffer.from( - '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"s1","update":{"sessionUpdate":"current_mode_update","currentModeId":"code"}}}\n', - ), - ); - - expect((service as any).sessionModes.currentModeId).toBe('code'); - }); - - it('should warn if current_mode_update received but sessionModes not initialized', () => { - setTransport(); - (service as any).sessionModes = null; - - mockStdout.emit( - 'data', - Buffer.from( - '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"s1","update":{"sessionUpdate":"current_mode_update","currentModeId":"code"}}}\n', - ), - ); - - expect(mockLogger.warn).toHaveBeenCalled(); - }); - }); - - describe('handleIncomingRequest()', () => { - it('should route fs/read_text_file to handler', async () => { - setTransport(); - mockAgentRequestHandler.handleReadTextFile.mockResolvedValue({ content: 'hello' }); - - const writeSpy = jest.spyOn(mockStdin, 'write'); - - mockStdout.emit( - 'data', - Buffer.from( - '{"jsonrpc":"2.0","id":1,"method":"fs/read_text_file","params":{"sessionId":"s1","path":"test.txt"}}\n', - ), - ); - - await new Promise((r) => setTimeout(r, 10)); - - expect(mockAgentRequestHandler.handleReadTextFile).toHaveBeenCalledWith({ - sessionId: 's1', - path: 'test.txt', - }); - expect(writeSpy).toHaveBeenCalledWith(expect.stringContaining('"result":{"content":"hello"}')); - }); - - it('should return method not found for unknown methods', async () => { - setTransport(); - const writeSpy = jest.spyOn(mockStdin, 'write'); - - mockStdout.emit('data', Buffer.from('{"jsonrpc":"2.0","id":1,"method":"unknown/method","params":{}}\n')); - - await new Promise((r) => setTimeout(r, 10)); - - expect(writeSpy).toHaveBeenCalledWith(expect.stringContaining('"code":-32601')); - }); - - it('should send error response when handler throws', async () => { - setTransport(); - mockAgentRequestHandler.handleReadTextFile.mockRejectedValue(new Error('read failed')); - const writeSpy = jest.spyOn(mockStdin, 'write'); - - mockStdout.emit( - 'data', - Buffer.from( - '{"jsonrpc":"2.0","id":1,"method":"fs/read_text_file","params":{"sessionId":"s1","path":"test.txt"}}\n', - ), - ); - - await new Promise((r) => setTimeout(r, 10)); - - expect(writeSpy).toHaveBeenCalledWith(expect.stringContaining('"error"')); - }); - }); - - describe('handleDisconnect on stdout events', () => { - it('should handle stdout end event', () => { - setTransport(); - const disconnectSpy = jest.spyOn(service, 'handleDisconnect'); - - mockStdout.emit('end'); - - expect(disconnectSpy).toHaveBeenCalled(); - expect(mockLogger.error).toHaveBeenCalled(); - }); - - it('should handle stdout error event', () => { - setTransport(); - const disconnectSpy = jest.spyOn(service, 'handleDisconnect'); - - mockStdout.emit('error', new Error('stream error')); - - expect(disconnectSpy).toHaveBeenCalled(); - expect(mockLogger.error).toHaveBeenCalled(); - }); - }); - - describe('sendNotification()', () => { - it('should send notification without id', () => { - setTransport(); - service.cancel({ sessionId: 's1' }); - - expect(mockStdin.write).toHaveBeenCalledWith(expect.stringContaining('"method":"session/cancel"')); - }); - - it('should not send notification when disconnected', () => { - service.cancel({ sessionId: 's1' }); - expect(mockStdin.write).not.toHaveBeenCalled(); - }); - - it('should handle write errors gracefully', () => { - setTransport(); - mockStdin.write.mockImplementationOnce(() => { - throw new Error('write failed'); - }); - - expect(() => service.cancel({ sessionId: 's1' })).not.toThrow(); - expect(mockLogger.warn).toHaveBeenCalled(); - }); - }); - - describe('getSessionModes()', () => { - it('should return session modes after initialize', async () => { - setTransport(); - jest.spyOn(service as any, 'sendRequest').mockResolvedValue({ - protocolVersion: ACP_PROTOCOL_VERSION, - modes: { currentModeId: 'code', availableModes: [{ id: 'code', name: 'Code' }] }, - }); - - await service.initialize(); - - expect(service.getSessionModes()).toEqual({ - currentModeId: 'code', - availableModes: [{ id: 'code', name: 'Code' }], - }); - }); - }); -}); diff --git a/packages/ai-native/__test__/node/acp-cli-process-manager.test.ts b/packages/ai-native/__test__/node/acp-cli-process-manager.test.ts deleted file mode 100644 index d3d58e6dfb..0000000000 --- a/packages/ai-native/__test__/node/acp-cli-process-manager.test.ts +++ /dev/null @@ -1,227 +0,0 @@ -jest.mock('@opensumi/di', () => { - const actual = jest.requireActual('@opensumi/di'); - const noopDecorator = () => () => {}; - return { - ...actual, - Injectable: () => (cls: any) => cls, - Autowired: noopDecorator, - Inject: noopDecorator, - Optional: noopDecorator, - }; -}); - -import { EventEmitter } from 'events'; - -// Create a mock child process for each test -function createMockChildProcess(pid = 12345) { - const mock = new EventEmitter() as any; - mock.pid = pid; - mock.killed = false; - mock.exitCode = null; - mock.signalCode = null; - mock.stdio = [new EventEmitter(), new EventEmitter(), new EventEmitter()]; - mock.stderr = new EventEmitter(); - return mock; -} - -const mockSpawn = jest.fn(); - -jest.mock('child_process', () => ({ - spawn: (...args: any[]) => mockSpawn(...args), -})); - -import { CliAgentProcessManager } from '../../src/node/acp/cli-agent-process-manager'; - -const mockLogger = { - log: jest.fn(), - error: jest.fn(), - debug: jest.fn(), - verbose: jest.fn(), - warn: jest.fn(), - critical: jest.fn(), - dispose: jest.fn(), - getLevel: jest.fn(), - setLevel: jest.fn(), -}; - -describe('CliAgentProcessManager', () => { - let manager: CliAgentProcessManager; - let mockChildProcess: ReturnType; - - beforeEach(() => { - mockChildProcess = createMockChildProcess(); - mockSpawn.mockImplementation(() => mockChildProcess); - - jest.spyOn(process, 'kill').mockImplementation((pid: number, signal: number | NodeJS.Signals): any => undefined); - - manager = new CliAgentProcessManager(); - Object.defineProperty(manager, 'logger', { value: mockLogger, writable: true }); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - describe('startAgent()', () => { - it('should spawn a new process and return process info', async () => { - const result = await manager.startAgent('npx', ['test'], {}, '/test/workspace'); - - expect(result.processId).toBe('12345'); - expect(mockSpawn).toHaveBeenCalledTimes(1); - }); - }); - - describe('stopAgent()', () => { - it('should do nothing when no process running', async () => { - await manager.stopAgent(); - - expect(mockLogger.warn).toHaveBeenCalled(); - }); - }); - - describe('killAgent()', () => { - it('should clear references when no process', async () => { - await manager.killAgent(); - - expect((manager as any).currentProcess).toBeNull(); - }); - }); - - describe('isRunning()', () => { - it('should return false when no process', () => { - expect(manager.isRunning()).toBe(false); - }); - - it('should return true for running process', async () => { - await manager.startAgent('npx', ['test'], {}, '/test/workspace'); - - expect(manager.isRunning()).toBe(true); - }); - - it('should return false when process killed flag is set', async () => { - await manager.startAgent('npx', ['test'], {}, '/test/workspace'); - - mockChildProcess.killed = true; - expect(manager.isRunning()).toBe(false); - }); - - it('should return false when process has exitCode', async () => { - await manager.startAgent('npx', ['test'], {}, '/test/workspace'); - - mockChildProcess.exitCode = 0; - expect(manager.isRunning()).toBe(false); - }); - }); - - describe('getExitCode()', () => { - it('should return null when no process', () => { - expect(manager.getExitCode()).toBeNull(); - }); - - it('should return exitCode from process', async () => { - await manager.startAgent('npx', ['test'], {}, '/test/workspace'); - - mockChildProcess.exitCode = 42; - expect(manager.getExitCode()).toBe(42); - }); - }); - - describe('listRunningAgents()', () => { - it('should return singleton ID when running', async () => { - await manager.startAgent('npx', ['test'], {}, '/test/workspace'); - - const agents = manager.listRunningAgents(); - - expect(agents).toEqual(['singleton-agent-process']); - }); - - it('should return empty array when not running', () => { - expect(manager.listRunningAgents()).toEqual([]); - }); - }); - - describe('killAllAgents()', () => { - it('should delegate to forceKillInternal', async () => { - const forceKillSpy = jest.spyOn(manager as any, 'forceKillInternal').mockResolvedValue(undefined); - - await manager.killAllAgents(); - - expect(forceKillSpy).toHaveBeenCalled(); - }); - }); - - describe('handleProcessExit()', () => { - it('should clear references on exit', async () => { - await manager.startAgent('npx', ['test'], {}, '/test/workspace'); - - mockChildProcess.emit('exit', 0, null); - - expect((manager as any).currentProcess).toBeNull(); - expect((manager as any).currentCommand).toBeNull(); - expect((manager as any).currentCwd).toBeNull(); - }); - }); - - describe('killProcessGroup()', () => { - it('should try process group kill first', () => { - const result = (manager as any).killProcessGroup(12345, 'SIGTERM'); - - expect(result).toBe(true); - expect(process.kill).toHaveBeenCalledWith(-12345, 'SIGTERM'); - }); - - it('should fallback to single process kill when group kill fails', () => { - const mockKill = process.kill as jest.Mock; - mockKill - .mockImplementationOnce(() => { - throw new Error('group not found'); - }) - .mockImplementation(() => true); - - const result = (manager as any).killProcessGroup(12345, 'SIGTERM'); - - expect(result).toBe(true); - expect(mockKill).toHaveBeenCalledWith(12345, 'SIGTERM'); - }); - - it('should return false when both kills fail', () => { - (process.kill as jest.Mock).mockImplementation(() => { - throw new Error('not found'); - }); - - const result = (manager as any).killProcessGroup(12345, 'SIGTERM'); - - expect(result).toBe(false); - }); - }); - - describe('wrapError()', () => { - it('should return user-friendly message for ENOENT', () => { - const err = new Error('spawn ENOENT'); - (err as any).code = 'ENOENT'; - - const result = (manager as any).wrapError(err, 'npx'); - - expect(result.message).toContain('Command not found'); - expect(result.message).toContain('npx'); - }); - - it('should return user-friendly message for EACCES', () => { - const err = new Error('spawn EACCES'); - (err as any).code = 'EACCES'; - - const result = (manager as any).wrapError(err, 'npx'); - - expect(result.message).toContain('Permission denied'); - }); - - it('should return original error for other codes', () => { - const err = new Error('some error'); - (err as any).code = 'OTHER'; - - const result = (manager as any).wrapError(err, 'npx'); - - expect(result).toBe(err); - }); - }); -}); diff --git a/packages/ai-native/__test__/node/acp-file-system-handler.test.ts b/packages/ai-native/__test__/node/acp-file-system-handler.test.ts index c2503909e0..93bdf3c06a 100644 --- a/packages/ai-native/__test__/node/acp-file-system-handler.test.ts +++ b/packages/ai-native/__test__/node/acp-file-system-handler.test.ts @@ -81,8 +81,12 @@ describe('AcpFileSystemHandler', () => { it('should reject path traversal with ..', () => { mockFs.realpathSync.mockImplementation((p: string) => { - if (p === '/test/workspace') {return '/test/workspace';} - if (p === '/test/workspace/../etc/passwd') {return '/etc/passwd';} + if (p === '/test/workspace') { + return '/test/workspace'; + } + if (p === '/test/workspace/../etc/passwd') { + return '/etc/passwd'; + } return p; }); @@ -193,13 +197,6 @@ describe('AcpFileSystemHandler', () => { expect(result.error).toBeDefined(); }); - it('should return error when content is missing', async () => { - const result = await handler.writeTextFile({ sessionId: 'sess-1', path: 'test.txt' }); - - expect(result.error).toBeDefined(); - expect(result.error?.code).toBe(ACPErrorCode.INVALID_PARAMS); - }); - it('should create parent directories if needed', async () => { mockFileService.getFileStat .mockResolvedValueOnce(null) // parent doesn't exist @@ -214,34 +211,6 @@ describe('AcpFileSystemHandler', () => { expect(mockFileService.createFolder).toHaveBeenCalled(); }); - it('should check permission callback before writing', async () => { - mockFileService.getFileStat.mockResolvedValueOnce({ isDirectory: true }).mockResolvedValueOnce(null); - - const permitted = await handler.writeTextFile({ - sessionId: 'sess-1', - path: 'test.txt', - content: 'Hello', - }); - - // No permission callback set by default, should proceed - expect(permitted.error).toBeUndefined(); - }); - - it('should deny write when permission callback returns false', async () => { - const denyCallback = jest.fn().mockResolvedValue(false); - handler.setPermissionCallback(denyCallback); - - const result = await handler.writeTextFile({ - sessionId: 'sess-1', - path: 'test.txt', - content: 'Hello', - }); - - expect(result.error).toBeDefined(); - expect(result.error?.code).toBe(ACPErrorCode.FORBIDDEN); - expect(denyCallback).toHaveBeenCalled(); - }); - it('should update existing file', async () => { mockFileService.getFileStat .mockResolvedValueOnce({ isDirectory: true }) @@ -257,107 +226,6 @@ describe('AcpFileSystemHandler', () => { }); }); - describe('getFileMeta()', () => { - it('should return meta for existing file', async () => { - mockFileService.getFileStat.mockResolvedValue({ - size: 1024, - lastModification: 1234567890, - isDirectory: false, - }); - - const result = await handler.getFileMeta({ sessionId: 'sess-1', path: 'test.ts' }); - - expect(result.size).toBe(1024); - expect(result.mtime).toBe(1234567890); - expect(result.isFile).toBe(true); - expect(result.mimeType).toBe('application/typescript'); - }); - - it('should return false for non-existing file', async () => { - mockFileService.getFileStat.mockResolvedValue(null); - - const result = await handler.getFileMeta({ sessionId: 'sess-1', path: 'nonexistent.txt' }); - - expect(result.isFile).toBe(false); - expect(result.size).toBe(0); - expect(result.mtime).toBe(0); - }); - }); - - describe('listDirectory()', () => { - it('should return entries for valid directory', async () => { - mockFileService.getFileStat.mockResolvedValue({ - isDirectory: true, - children: [ - { uri: 'file:///test/workspace/src', isDirectory: true, size: 0 }, - { uri: 'file:///test/workspace/index.ts', isDirectory: false, size: 100 }, - ], - }); - - const result = await handler.listDirectory({ sessionId: 'sess-1', path: '.' }); - - expect(result.entries).toHaveLength(2); - expect(result.entries![0].name).toBe('src'); - expect(result.entries![1].name).toBe('index.ts'); - }); - - it('should return error when path is a file', async () => { - mockFileService.getFileStat.mockResolvedValue({ isDirectory: false }); - - const result = await handler.listDirectory({ sessionId: 'sess-1', path: 'test.txt' }); - - expect(result.error).toBeDefined(); - expect(result.error?.message).toContain('not a directory'); - }); - - it('should return error when directory not found', async () => { - mockFileService.getFileStat.mockResolvedValue(null); - - const result = await handler.listDirectory({ sessionId: 'sess-1', path: 'nonexistent' }); - - expect(result.error).toBeDefined(); - expect(result.error?.code).toBe(ACPErrorCode.RESOURCE_NOT_FOUND); - }); - - it('should include subdirectory entries when recursive', async () => { - mockFileService.getFileStat.mockResolvedValue({ - isDirectory: true, - children: [ - { - uri: 'file:///test/workspace/src', - isDirectory: true, - size: 0, - children: [{ uri: 'file:///test/workspace/src/index.ts', isDirectory: false, size: 200 }], - }, - ], - }); - - const result = await handler.listDirectory({ sessionId: 'sess-1', path: '.', recursive: true }); - - expect(result.entries).toHaveLength(2); - expect(result.entries![1].name).toBe('src/index.ts'); - }); - }); - - describe('createDirectory()', () => { - it('should create directory successfully', async () => { - const result = await handler.createDirectory({ sessionId: 'sess-1', path: 'new-dir' }); - - expect(result.error).toBeUndefined(); - expect(mockFileService.createFolder).toHaveBeenCalled(); - }); - - it('should check permission callback', async () => { - const denyCallback = jest.fn().mockResolvedValue(false); - handler.setPermissionCallback(denyCallback); - - const result = await handler.createDirectory({ sessionId: 'sess-1', path: 'new-dir' }); - - expect(result.error).toBeDefined(); - expect(result.error?.code).toBe(ACPErrorCode.FORBIDDEN); - }); - }); - describe('detectMimeType()', () => { const testCases: [string, string][] = [ ['test.ts', 'application/typescript'], diff --git a/packages/ai-native/__test__/node/acp-permission-caller.test.ts b/packages/ai-native/__test__/node/acp-permission-caller.test.ts index 5e6ef45033..c324adcefb 100644 --- a/packages/ai-native/__test__/node/acp-permission-caller.test.ts +++ b/packages/ai-native/__test__/node/acp-permission-caller.test.ts @@ -11,69 +11,24 @@ jest.mock('@opensumi/di', () => { }); import { - AcpPermissionCallerManager, AcpPermissionCallerManagerToken, + AcpPermissionCallerService, + AcpPermissionCallerServiceToken, } from '../../src/node/acp/acp-permission-caller.service'; -const mockLogger = { - log: jest.fn(), - error: jest.fn(), - debug: jest.fn(), - verbose: jest.fn(), - warn: jest.fn(), - critical: jest.fn(), - dispose: jest.fn(), - getLevel: jest.fn(), - setLevel: jest.fn(), -}; - const mockRpcClient = { $showPermissionDialog: jest.fn(), $cancelRequest: jest.fn(), }; -describe('AcpPermissionCallerManager', () => { - let manager: AcpPermissionCallerManager; +describe('AcpPermissionCallerService', () => { + let service: AcpPermissionCallerService; beforeEach(() => { jest.clearAllMocks(); - (AcpPermissionCallerManager as any).currentRpcClient = null; - - manager = new AcpPermissionCallerManager(); - Object.defineProperty(manager, 'logger', { value: mockLogger, writable: true }); - Object.defineProperty(manager, 'client', { value: mockRpcClient, writable: true }); - }); - - afterEach(() => { - (AcpPermissionCallerManager as any).currentRpcClient = null; - }); - - describe('setConnectionClientId()', () => { - it('should set clientId', () => { - manager.setConnectionClientId('client-1'); - - expect((manager as any).clientId).toBe('client-1'); - }); - - it('should update static currentRpcClient via microtask', async () => { - expect((AcpPermissionCallerManager as any).currentRpcClient).toBeNull(); - - manager.setConnectionClientId('client-1'); - - await Promise.resolve(); - - expect((AcpPermissionCallerManager as any).currentRpcClient).toBe(mockRpcClient); - }); - }); - - describe('removeConnectionClientId()', () => { - it('should clear clientId when matching', () => { - manager.setConnectionClientId('client-1'); - manager.removeConnectionClientId('client-1'); - - expect((manager as any).clientId).toBeUndefined(); - }); + service = new AcpPermissionCallerService(); + Object.defineProperty(service, 'rpcClient', { value: [mockRpcClient], writable: true }); }); describe('requestPermission() - skip mode', () => { @@ -86,15 +41,18 @@ describe('AcpPermissionCallerManager', () => { it('should return allow option when SKIP_PERMISSION_CHECK=true', async () => { process.env.SKIP_PERMISSION_CHECK = 'true'; - const result = await manager.requestPermission({ - sessionId: 'sess-1', - toolCall: { toolCallId: 'tc-1', title: 'Test', kind: 'read', status: 'pending' } as any, - options: [ - { optionId: 'allow_once', name: 'Allow Once', kind: 'allow_once' as const }, - { optionId: 'allow_always', name: 'Allow Always', kind: 'allow_always' as const }, - { optionId: 'reject_once', name: 'Reject Once', kind: 'reject_once' as const }, - ], - }); + const result = await service.requestPermission( + { + sessionId: 'sess-1', + toolCall: { toolCallId: 'tc-1', title: 'Test', kind: 'read', status: 'pending' } as any, + options: [ + { optionId: 'allow_once', name: 'Allow Once', kind: 'allow_once' as const }, + { optionId: 'allow_always', name: 'Allow Always', kind: 'allow_always' as const }, + { optionId: 'reject_once', name: 'Reject Once', kind: 'reject_once' as const }, + ], + }, + 'sess-1', + ); expect(result.outcome.outcome).toBe('selected'); expect(mockRpcClient.$showPermissionDialog).not.toHaveBeenCalled(); @@ -103,14 +61,17 @@ describe('AcpPermissionCallerManager', () => { it('should prefer allow_once over allow_always in skip mode', async () => { process.env.SKIP_PERMISSION_CHECK = 'true'; - const result = await manager.requestPermission({ - sessionId: 'sess-1', - toolCall: { toolCallId: 'tc-1', title: 'Test', kind: 'read', status: 'pending' } as any, - options: [ - { optionId: 'allow_always', name: 'Always', kind: 'allow_always' as const }, - { optionId: 'allow_once', name: 'Once', kind: 'allow_once' as const }, - ], - }); + const result = await service.requestPermission( + { + sessionId: 'sess-1', + toolCall: { toolCallId: 'tc-1', title: 'Test', kind: 'read', status: 'pending' } as any, + options: [ + { optionId: 'allow_always', name: 'Always', kind: 'allow_always' as const }, + { optionId: 'allow_once', name: 'Once', kind: 'allow_once' as const }, + ], + }, + 'sess-1', + ); expect((result.outcome as any).optionId).toBe('allow_once'); }); @@ -118,11 +79,14 @@ describe('AcpPermissionCallerManager', () => { it('should fallback to first option in skip mode when no allow options', async () => { process.env.SKIP_PERMISSION_CHECK = 'true'; - const result = await manager.requestPermission({ - sessionId: 'sess-1', - toolCall: { toolCallId: 'tc-1', title: 'Test', kind: 'read', status: 'pending' } as any, - options: [{ optionId: 'custom', name: 'Custom', kind: 'custom' as any }], - }); + const result = await service.requestPermission( + { + sessionId: 'sess-1', + toolCall: { toolCallId: 'tc-1', title: 'Test', kind: 'read', status: 'pending' } as any, + options: [{ optionId: 'custom', name: 'Custom', kind: 'custom' as any }], + }, + 'sess-1', + ); expect((result.outcome as any).optionId).toBe('custom'); }); @@ -130,84 +94,19 @@ describe('AcpPermissionCallerManager', () => { it('should return empty string in skip mode when no options', async () => { process.env.SKIP_PERMISSION_CHECK = 'true'; - const result = await manager.requestPermission({ - sessionId: 'sess-1', - toolCall: { toolCallId: 'tc-1', title: 'Test', kind: 'read', status: 'pending' } as any, - options: [], - }); + const result = await service.requestPermission( + { + sessionId: 'sess-1', + toolCall: { toolCallId: 'tc-1', title: 'Test', kind: 'read', status: 'pending' } as any, + options: [], + }, + 'sess-1', + ); expect((result.outcome as any).optionId).toBe(''); }); }); - describe('findAllowOptionId()', () => { - it('should prefer allow_once', () => { - const options = [ - { optionId: 'allow_always', name: 'Always', kind: 'allow_always' as const }, - { optionId: 'allow_once', name: 'Once', kind: 'allow_once' as const }, - ]; - - const result = (manager as any).findAllowOptionId(options); - expect(result).toBe('allow_once'); - }); - - it('should fallback to allow_always if no allow_once', () => { - const options = [{ optionId: 'allow_always', name: 'Always', kind: 'allow_always' as const }]; - - const result = (manager as any).findAllowOptionId(options); - expect(result).toBe('allow_always'); - }); - - it('should fallback to first option if no allow options', () => { - const options = [{ optionId: 'reject_once', name: 'Reject', kind: 'reject_once' as const }]; - - const result = (manager as any).findAllowOptionId(options); - expect(result).toBe('reject_once'); - }); - - it('should return empty string for empty options', () => { - const result = (manager as any).findAllowOptionId([]); - expect(result).toBe(''); - }); - }); - - describe('sortOptionsByKind()', () => { - it('should sort in correct order', () => { - const options = [ - { optionId: 'reject_once', kind: 'reject_once' as const }, - { optionId: 'allow_always', kind: 'allow_always' as const }, - { optionId: 'reject_always', kind: 'reject_always' as const }, - { optionId: 'allow_once', kind: 'allow_once' as const }, - ]; - - const result = (manager as any).sortOptionsByKind(options); - const kinds = result.map((o: any) => o.kind); - expect(kinds).toEqual(['allow_always', 'allow_once', 'reject_always', 'reject_once']); - }); - - it('should not mutate original array', () => { - const original = [ - { optionId: 'reject_once', kind: 'reject_once' as const }, - { optionId: 'allow_always', kind: 'allow_always' as const }, - ]; - - (manager as any).sortOptionsByKind(original); - - expect(original[0].kind).toBe('reject_once'); - }); - - it('should put unknown kinds at the end', () => { - const options = [ - { optionId: 'unknown', kind: 'unknown' as any }, - { optionId: 'allow_once', kind: 'allow_once' as const }, - ]; - - const result = (manager as any).sortOptionsByKind(options); - expect(result[0].kind).toBe('allow_once'); - expect(result[1].kind).toBe('unknown'); - }); - }); - describe('requestPermission() - normal RPC flow', () => { const originalEnv = process.env; @@ -223,18 +122,21 @@ describe('AcpPermissionCallerManager', () => { it('should call $showPermissionDialog with correct params', async () => { mockRpcClient.$showPermissionDialog.mockResolvedValue({ type: 'allow', optionId: 'allow_once' }); - const result = await manager.requestPermission({ - sessionId: 'sess-1', - toolCall: { - toolCallId: 'tc-1', - title: 'Run Command', - kind: 'execute', - status: 'pending', - locations: [{ path: '/src/test.ts', line: 10 }], - rawInput: { command: 'npm test' }, - } as any, - options: [{ optionId: 'allow_once', name: 'Allow Once', kind: 'allow_once' as const }], - }); + const result = await service.requestPermission( + { + sessionId: 'sess-1', + toolCall: { + toolCallId: 'tc-1', + title: 'Run Command', + kind: 'execute', + status: 'pending', + locations: [{ path: '/src/test.ts', line: 10 }], + rawInput: { command: 'npm test' }, + } as any, + options: [{ optionId: 'allow_once', name: 'Allow Once', kind: 'allow_once' as const }], + }, + 'sess-1', + ); expect(mockRpcClient.$showPermissionDialog).toHaveBeenCalledWith( expect.objectContaining({ @@ -255,18 +157,21 @@ describe('AcpPermissionCallerManager', () => { it('should build content with title, affected files, and command', async () => { mockRpcClient.$showPermissionDialog.mockResolvedValue({ type: 'allow' }); - await manager.requestPermission({ - sessionId: 'sess-1', - toolCall: { - toolCallId: 'tc-1', - title: 'Edit File', - kind: 'write', - status: 'pending', - locations: [{ path: '/src/a.ts' }, { path: '/src/b.ts' }], - rawInput: { command: 'write to file' }, - } as any, - options: [{ optionId: 'opt-1', name: 'Allow', kind: 'allow_once' as const }], - }); + await service.requestPermission( + { + sessionId: 'sess-1', + toolCall: { + toolCallId: 'tc-1', + title: 'Edit File', + kind: 'write', + status: 'pending', + locations: [{ path: '/src/a.ts' }, { path: '/src/b.ts' }], + rawInput: { command: 'write to file' }, + } as any, + options: [{ optionId: 'opt-1', name: 'Allow', kind: 'allow_once' as const }], + }, + 'sess-1', + ); const callArg = mockRpcClient.$showPermissionDialog.mock.calls[0][0]; expect(callArg.content).toContain('Edit File'); @@ -275,33 +180,35 @@ describe('AcpPermissionCallerManager', () => { }); it('should throw when no RPC client available', async () => { - (AcpPermissionCallerManager as any).currentRpcClient = null; - Object.defineProperty(manager, 'client', { value: null, writable: true }); + Object.defineProperty(service, 'rpcClient', { value: undefined, writable: true }); await expect( - manager.requestPermission({ - sessionId: 'sess-1', - toolCall: { toolCallId: 'tc-1', title: 'Test', kind: 'read', status: 'pending' } as any, - options: [{ optionId: 'opt-1', name: 'Allow', kind: 'allow_once' as const }], - }), + service.requestPermission( + { + sessionId: 'sess-1', + toolCall: { toolCallId: 'tc-1', title: 'Test', kind: 'read', status: 'pending' } as any, + options: [{ optionId: 'opt-1', name: 'Allow', kind: 'allow_once' as const }], + }, + 'sess-1', + ), ).rejects.toThrow('[ACP Permission Caller] No active RPC client available'); }); - it('should use static currentRpcClient as fallback', async () => { - const staticClient = { - $showPermissionDialog: jest.fn().mockResolvedValue({ type: 'allow' }), - $cancelRequest: jest.fn(), - }; - (AcpPermissionCallerManager as any).currentRpcClient = staticClient; - Object.defineProperty(manager, 'client', { value: null, writable: true }); - - await manager.requestPermission({ - sessionId: 'sess-1', - toolCall: { toolCallId: 'tc-1', title: 'Test', kind: 'read', status: 'pending' } as any, - options: [{ optionId: 'opt-1', name: 'Allow', kind: 'allow_once' as const }], - }); - - expect(staticClient.$showPermissionDialog).toHaveBeenCalled(); + it('should use the provided sessionId for the dialog requestId', async () => { + mockRpcClient.$showPermissionDialog.mockResolvedValue({ type: 'allow' }); + + await service.requestPermission( + { + sessionId: 'sdk-session', + toolCall: { toolCallId: 'tc-42', title: 'Test', kind: 'read', status: 'pending' } as any, + options: [{ optionId: 'opt-1', name: 'Allow', kind: 'allow_once' as const }], + }, + 'routed-session', + ); + + const callArg = mockRpcClient.$showPermissionDialog.mock.calls[0][0]; + expect(callArg.sessionId).toBe('routed-session'); + expect(callArg.requestId).toBe('routed-session:tc-42'); }); }); @@ -314,84 +221,79 @@ describe('AcpPermissionCallerManager', () => { ]; it('should return selected outcome for allow decision', () => { - const result = (manager as any).buildPermissionResponse({ type: 'allow', optionId: 'allow_once' }, options); + const result = (service as any).buildPermissionResponse({ type: 'allow', optionId: 'allow_once' }, options); expect(result.outcome.outcome).toBe('selected'); expect(result.outcome.optionId).toBe('allow_once'); }); it('should return selected outcome for reject decision', () => { - const result = (manager as any).buildPermissionResponse({ type: 'reject', optionId: 'reject_once' }, options); + const result = (service as any).buildPermissionResponse({ type: 'reject', optionId: 'reject_once' }, options); expect(result.outcome.outcome).toBe('selected'); expect(result.outcome.optionId).toBe('reject_once'); }); it('should auto-find optionId when not provided in allow decision', () => { - const result = (manager as any).buildPermissionResponse({ type: 'allow' }, options); + const result = (service as any).buildPermissionResponse({ type: 'allow' }, options); expect(result.outcome.outcome).toBe('selected'); expect(result.outcome.optionId).toBe('allow_once'); }); it('should auto-find optionId when not provided in reject decision', () => { - const result = (manager as any).buildPermissionResponse({ type: 'reject' }, options); + const result = (service as any).buildPermissionResponse({ type: 'reject' }, options); expect(result.outcome.outcome).toBe('selected'); expect(result.outcome.optionId).toBe('reject_once'); }); it('should return cancelled outcome for timeout decision', () => { - const result = (manager as any).buildPermissionResponse({ type: 'timeout' }, options); + const result = (service as any).buildPermissionResponse({ type: 'timeout' }, options); expect(result.outcome.outcome).toBe('cancelled'); }); it('should return cancelled outcome for cancelled decision', () => { - const result = (manager as any).buildPermissionResponse({ type: 'cancelled' }, options); + const result = (service as any).buildPermissionResponse({ type: 'cancelled' }, options); expect(result.outcome.outcome).toBe('cancelled'); }); it('should return cancelled outcome for unknown decision type', () => { - const result = (manager as any).buildPermissionResponse({ type: 'unknown' as any }, options); + const result = (service as any).buildPermissionResponse({ type: 'unknown' as any }, options); expect(result.outcome.outcome).toBe('cancelled'); }); }); - describe('findOptionId()', () => { - const options = [ - { optionId: 'allow_once', name: 'Allow Once', kind: 'allow_once' as const }, - { optionId: 'allow_always', name: 'Allow Always', kind: 'allow_always' as const }, - { optionId: 'reject_once', name: 'Reject Once', kind: 'reject_once' as const }, - { optionId: 'reject_always', name: 'Reject Always', kind: 'reject_always' as const }, - ]; + describe('sortOptionsByKind()', () => { + it('should sort in correct order', () => { + const options = [ + { optionId: 'reject_once', kind: 'reject_once' as const }, + { optionId: 'allow_always', kind: 'allow_always' as const }, + { optionId: 'reject_always', kind: 'reject_always' as const }, + { optionId: 'allow_once', kind: 'allow_once' as const }, + ]; - it('should find allow_once for allow decision', () => { - const result = (manager as any).findOptionId('allow', options); - expect(result).toBe('allow_once'); + const result = (service as any).sortOptionsByKind(options); + const kinds = result.map((o: any) => o.kind); + expect(kinds).toEqual(['allow_always', 'allow_once', 'reject_always', 'reject_once']); }); - it('should find reject_once for reject decision', () => { - const result = (manager as any).findOptionId('reject', options); - expect(result).toBe('reject_once'); - }); + it('should not mutate original array', () => { + const original = [ + { optionId: 'reject_once', kind: 'reject_once' as const }, + { optionId: 'allow_always', kind: 'allow_always' as const }, + ]; - it('should fallback to allow_always when no allow_once', () => { - const opts = options.filter((o) => o.kind !== 'allow_once'); - const result = (manager as any).findOptionId('allow', opts); - expect(result).toBe('allow_always'); - }); + (service as any).sortOptionsByKind(original); - it('should fallback to prefix match when no exact kind match', () => { - const opts = [{ optionId: 'allow_custom', name: 'Custom', kind: 'allow_custom' as any }]; - const result = (manager as any).findOptionId('allow', opts); - expect(result).toBe('allow_custom'); + expect(original[0].kind).toBe('reject_once'); }); - it('should fallback to first option when no match', () => { - const opts = [{ optionId: 'custom', name: 'Custom', kind: 'custom' as any }]; - const result = (manager as any).findOptionId('allow', opts); - expect(result).toBe('custom'); - }); + it('should put unknown kinds at the end', () => { + const options = [ + { optionId: 'unknown', kind: 'unknown' as any }, + { optionId: 'allow_once', kind: 'allow_once' as const }, + ]; - it('should return empty string for empty options', () => { - const result = (manager as any).findOptionId('allow', []); - expect(result).toBe(''); + const result = (service as any).sortOptionsByKind(options); + expect(result[0].kind).toBe('allow_once'); + expect(result[1].kind).toBe('unknown'); }); }); @@ -399,70 +301,21 @@ describe('AcpPermissionCallerManager', () => { it('should call $cancelRequest on rpc client', async () => { mockRpcClient.$cancelRequest.mockResolvedValue(undefined); - await manager.cancelRequest('req-123'); + await service.cancelRequest('req-123'); expect(mockRpcClient.$cancelRequest).toHaveBeenCalledWith('req-123'); }); - it('should use static currentRpcClient as fallback', async () => { - const staticClient = { - $showPermissionDialog: jest.fn(), - $cancelRequest: jest.fn().mockResolvedValue(undefined), - }; - (AcpPermissionCallerManager as any).currentRpcClient = staticClient; - Object.defineProperty(manager, 'client', { value: null, writable: true }); - - await manager.cancelRequest('req-456'); - - expect(staticClient.$cancelRequest).toHaveBeenCalledWith('req-456'); - }); - it('should not throw when rpc client is unavailable', async () => { - (AcpPermissionCallerManager as any).currentRpcClient = null; - Object.defineProperty(manager, 'client', { value: null, writable: true }); + Object.defineProperty(service, 'rpcClient', { value: undefined, writable: true }); - await expect(manager.cancelRequest('req-789')).resolves.not.toThrow(); - }); - - it('should log error when $cancelRequest fails', async () => { - mockRpcClient.$cancelRequest.mockRejectedValue(new Error('Network error')); - - await manager.cancelRequest('req-123'); - - expect(mockLogger.error).toHaveBeenCalledWith( - '[ACP Permission Caller] Failed to cancel request:', - expect.any(Error), - ); + await expect(service.cancelRequest('req-789')).resolves.not.toThrow(); }); }); - describe('removeConnectionClientId() - edge cases', () => { - it('should not clear clientId when mismatched', () => { - manager.setConnectionClientId('client-1'); - manager.removeConnectionClientId('client-2'); - - expect((manager as any).clientId).toBe('client-1'); - }); - - it('should not clear static currentRpcClient when client mismatched', () => { - const otherClient = { $showPermissionDialog: jest.fn(), $cancelRequest: jest.fn() }; - (AcpPermissionCallerManager as any).currentRpcClient = otherClient; - - manager.setConnectionClientId('client-1'); - manager.removeConnectionClientId('client-2'); - - expect((AcpPermissionCallerManager as any).currentRpcClient).toBe(otherClient); - }); - - it('should clear static currentRpcClient when matching', async () => { - manager.setConnectionClientId('client-1'); - await Promise.resolve(); - - expect((AcpPermissionCallerManager as any).currentRpcClient).toBe(mockRpcClient); - - manager.removeConnectionClientId('client-1'); - - expect((AcpPermissionCallerManager as any).currentRpcClient).toBeNull(); + describe('backward compatibility tokens', () => { + it('AcpPermissionCallerManagerToken should equal AcpPermissionCallerServiceToken', () => { + expect(AcpPermissionCallerManagerToken).toBe(AcpPermissionCallerServiceToken); }); }); }); diff --git a/packages/ai-native/__test__/node/acp-terminal-handler.test.ts b/packages/ai-native/__test__/node/acp-terminal-handler.test.ts index cce1be00d2..f39a95cc60 100644 --- a/packages/ai-native/__test__/node/acp-terminal-handler.test.ts +++ b/packages/ai-native/__test__/node/acp-terminal-handler.test.ts @@ -24,7 +24,6 @@ jest.mock('node-pty', () => ({ import pty from 'node-pty'; -import { ACPErrorCode } from '../../src/node/acp/handlers/constants'; import { AcpTerminalHandler, AcpTerminalHandlerToken } from '../../src/node/acp/handlers/terminal.handler'; const mockLogger = { @@ -73,15 +72,6 @@ describe('AcpTerminalHandler', () => { }); }); - describe('setPermissionCallback()', () => { - it('should set the callback', () => { - const cb = jest.fn(); - handler.setPermissionCallback(cb); - - expect((handler as any).permissionCallback).toBe(cb); - }); - }); - describe('createTerminal()', () => { const baseRequest = { sessionId: 'sess-1', @@ -97,38 +87,6 @@ describe('AcpTerminalHandler', () => { expect(pty.spawn).toHaveBeenCalledWith('bash', ['-c', 'echo hello'], expect.any(Object)); }); - it('should default to /bin/sh when no command provided', async () => { - await handler.createTerminal({ sessionId: 'sess-1' }); - - expect(pty.spawn).toHaveBeenCalledWith('/bin/sh', [], expect.any(Object)); - }); - - it('should deny creation when permission callback returns false', async () => { - handler.setPermissionCallback(jest.fn().mockResolvedValue(false)); - - const result = await handler.createTerminal(baseRequest); - - expect(result.error).toBeDefined(); - expect(result.error?.code).toBe(ACPErrorCode.FORBIDDEN); - expect(result.error?.message).toContain('permission denied'); - }); - - it('should allow creation when permission callback returns true', async () => { - handler.setPermissionCallback(jest.fn().mockResolvedValue(true)); - - const result = await handler.createTerminal(baseRequest); - - expect(result.error).toBeUndefined(); - expect(result.terminalId).toBeDefined(); - }); - - it('should create directly without permission callback', async () => { - const result = await handler.createTerminal(baseRequest); - - expect(result.error).toBeUndefined(); - expect(pty.spawn).toHaveBeenCalled(); - }); - it('should merge environment variables', async () => { await handler.createTerminal({ sessionId: 'sess-1', @@ -193,10 +151,7 @@ describe('AcpTerminalHandler', () => { describe('getTerminalOutput()', () => { it('should return terminal not found error for unknown terminal', async () => { - const result = await handler.getTerminalOutput({ - sessionId: 'sess-1', - terminalId: 'unknown', - }); + const result = await handler.getTerminalOutput('unknown', 'sess-1'); expect(result.error).toBeDefined(); expect(result.error?.message).toBe('Terminal not found'); @@ -206,10 +161,7 @@ describe('AcpTerminalHandler', () => { const createResult = await handler.createTerminal({ sessionId: 'sess-1', command: 'bash' }); const terminalId = createResult.terminalId!; - const result = await handler.getTerminalOutput({ - sessionId: 'sess-2', - terminalId, - }); + const result = await handler.getTerminalOutput(terminalId, 'sess-2'); expect(result.error).toBeDefined(); expect(result.error?.message).toBe('Session mismatch'); @@ -223,7 +175,7 @@ describe('AcpTerminalHandler', () => { const session = (handler as any).terminals.get(terminalId); session.outputBuffer = 'hello world'; - const result = await handler.getTerminalOutput({ sessionId: 'sess-1', terminalId }); + const result = await handler.getTerminalOutput(terminalId, 'sess-1'); expect(result.output).toBe('hello world'); expect(result.truncated).toBe(false); @@ -240,7 +192,7 @@ describe('AcpTerminalHandler', () => { const session = (handler as any).terminals.get(terminalId); session.outputBuffer = 'This is a long output string that exceeds the limit'; - const result = await handler.getTerminalOutput({ sessionId: 'sess-1', terminalId }); + const result = await handler.getTerminalOutput(terminalId, 'sess-1'); expect(result.truncated).toBe(true); }); @@ -253,18 +205,18 @@ describe('AcpTerminalHandler', () => { session.exited = true; session.exitCode = 0; - const result = await handler.getTerminalOutput({ sessionId: 'sess-1', terminalId }); + const result = await handler.getTerminalOutput(terminalId, 'sess-1'); expect(result.exitStatus).toBe(0); }); - it('should return null exitStatus when still running', async () => { + it('should return undefined exitStatus when still running', async () => { const createResult = await handler.createTerminal({ sessionId: 'sess-1', command: 'bash' }); const terminalId = createResult.terminalId!; - const result = await handler.getTerminalOutput({ sessionId: 'sess-1', terminalId }); + const result = await handler.getTerminalOutput(terminalId, 'sess-1'); - expect(result.exitStatus).toBe(null); + expect(result.exitStatus).toBeUndefined(); }); }); @@ -277,16 +229,13 @@ describe('AcpTerminalHandler', () => { session.exited = true; session.exitCode = 42; - const result = await handler.waitForTerminalExit({ sessionId: 'sess-1', terminalId }); + const result = await handler.waitForTerminalExit(terminalId, 'sess-1'); expect(result.exitCode).toBe(42); }); it('should return terminal not found error', async () => { - const result = await handler.waitForTerminalExit({ - sessionId: 'sess-1', - terminalId: 'unknown', - }); + const result = await handler.waitForTerminalExit('unknown', 'sess-1'); expect(result.error).toBeDefined(); expect(result.error?.message).toBe('Terminal not found'); @@ -296,45 +245,30 @@ describe('AcpTerminalHandler', () => { const createResult = await handler.createTerminal({ sessionId: 'sess-1', command: 'bash' }); const terminalId = createResult.terminalId!; - const result = await handler.waitForTerminalExit({ - sessionId: 'sess-2', - terminalId, - }); + const result = await handler.waitForTerminalExit(terminalId, 'sess-2'); expect(result.error).toBeDefined(); expect(result.error?.message).toBe('Session mismatch'); }); - it('should return null exitStatus on timeout', async () => { + it('should return empty object on timeout', async () => { const createResult = await handler.createTerminal({ sessionId: 'sess-1', command: 'bash' }); const terminalId = createResult.terminalId!; - const exitPromise = handler.waitForTerminalExit({ - sessionId: 'sess-1', - terminalId, - timeout: 1000, - }); + const exitPromise = handler.waitForTerminalExit(terminalId, 'sess-1'); - jest.advanceTimersByTime(1500); + jest.advanceTimersByTime(31000); const result = await exitPromise; - expect(result.exitStatus).toBe(null); + expect(result.exitCode).toBeUndefined(); + expect(result.error).toBeUndefined(); }); it('should return exitCode when terminal exits within timeout', async () => { - let exitCallback: Function | null = null; - mockPtyProcess.onExit.mockImplementation((cb: Function) => { - exitCallback = cb; - }); - const createResult = await handler.createTerminal({ sessionId: 'sess-1', command: 'bash' }); const terminalId = createResult.terminalId!; - const exitPromise = handler.waitForTerminalExit({ - sessionId: 'sess-1', - terminalId, - timeout: 5000, - }); + const exitPromise = handler.waitForTerminalExit(terminalId, 'sess-1'); // Simulate terminal exit const session = (handler as any).terminals.get(terminalId); @@ -350,10 +284,7 @@ describe('AcpTerminalHandler', () => { describe('killTerminal()', () => { it('should return terminal not found error', async () => { - const result = await handler.killTerminal({ - sessionId: 'sess-1', - terminalId: 'unknown', - }); + const result = await handler.killTerminal('unknown', 'sess-1'); expect(result.error).toBeDefined(); expect(result.error?.message).toBe('Terminal not found'); @@ -363,16 +294,13 @@ describe('AcpTerminalHandler', () => { const createResult = await handler.createTerminal({ sessionId: 'sess-1', command: 'bash' }); const terminalId = createResult.terminalId!; - const result = await handler.killTerminal({ - sessionId: 'sess-2', - terminalId, - }); + const result = await handler.killTerminal(terminalId, 'sess-2'); expect(result.error).toBeDefined(); expect(result.error?.message).toBe('Session mismatch'); }); - it('should return exitStatus when already exited', async () => { + it('should return empty when already exited', async () => { const createResult = await handler.createTerminal({ sessionId: 'sess-1', command: 'bash' }); const terminalId = createResult.terminalId!; @@ -380,9 +308,9 @@ describe('AcpTerminalHandler', () => { session.exited = true; session.exitCode = 1; - const result = await handler.killTerminal({ sessionId: 'sess-1', terminalId }); + const result = await handler.killTerminal(terminalId, 'sess-1'); - expect(result.exitStatus).toBe(1); + expect(result.error).toBeUndefined(); expect(mockPtyProcess.kill).not.toHaveBeenCalled(); }); @@ -390,7 +318,7 @@ describe('AcpTerminalHandler', () => { const createResult = await handler.createTerminal({ sessionId: 'sess-1', command: 'bash' }); const terminalId = createResult.terminalId!; - const killPromise = handler.killTerminal({ sessionId: 'sess-1', terminalId }); + const killPromise = handler.killTerminal(terminalId, 'sess-1'); // Simulate exit after kill jest.advanceTimersByTime(50); @@ -407,10 +335,7 @@ describe('AcpTerminalHandler', () => { describe('releaseTerminal()', () => { it('should return empty when terminal does not exist', async () => { - const result = await handler.releaseTerminal({ - sessionId: 'sess-1', - terminalId: 'unknown', - }); + const result = await handler.releaseTerminal('unknown', 'sess-1'); expect(result).toEqual({}); }); @@ -419,10 +344,7 @@ describe('AcpTerminalHandler', () => { const createResult = await handler.createTerminal({ sessionId: 'sess-1', command: 'bash' }); const terminalId = createResult.terminalId!; - const result = await handler.releaseTerminal({ - sessionId: 'sess-2', - terminalId, - }); + const result = await handler.releaseTerminal(terminalId, 'sess-2'); expect(result.error).toBeDefined(); expect(result.error?.message).toBe('Session mismatch'); @@ -432,7 +354,7 @@ describe('AcpTerminalHandler', () => { const createResult = await handler.createTerminal({ sessionId: 'sess-1', command: 'bash' }); const terminalId = createResult.terminalId!; - await handler.releaseTerminal({ sessionId: 'sess-1', terminalId }); + await handler.releaseTerminal(terminalId, 'sess-1'); expect((handler as any).terminals.has(terminalId)).toBe(false); }); @@ -441,7 +363,7 @@ describe('AcpTerminalHandler', () => { const createResult = await handler.createTerminal({ sessionId: 'sess-1', command: 'bash' }); const terminalId = createResult.terminalId!; - await handler.releaseTerminal({ sessionId: 'sess-1', terminalId }); + await handler.releaseTerminal(terminalId, 'sess-1'); expect(mockPtyProcess.kill).toHaveBeenCalled(); }); diff --git a/packages/ai-native/__test__/node/acp-thread-status-caller.test.ts b/packages/ai-native/__test__/node/acp-thread-status-caller.test.ts new file mode 100644 index 0000000000..a92edb3200 --- /dev/null +++ b/packages/ai-native/__test__/node/acp-thread-status-caller.test.ts @@ -0,0 +1,81 @@ +jest.mock('@opensumi/di', () => { + const actual = jest.requireActual('@opensumi/di'); + const noopDecorator = () => () => {}; + return { + ...actual, + Injectable: () => (cls: any) => cls, + Autowired: noopDecorator, + Inject: noopDecorator, + Optional: noopDecorator, + }; +}); + +import { AcpThreadStatusCallerService } from '../../src/node/acp/acp-thread-status-caller.service'; + +const mockRpcClient = { + $onThreadStatusChange: jest.fn().mockResolvedValue(undefined), +}; + +describe('AcpThreadStatusCallerService', () => { + let service: AcpThreadStatusCallerService; + + beforeEach(() => { + jest.clearAllMocks(); + AcpThreadStatusCallerService.staticRpcClient = undefined; + service = new AcpThreadStatusCallerService(); + Object.defineProperty(service, 'rpcClient', { value: [mockRpcClient], writable: true }); + }); + + afterEach(() => { + AcpThreadStatusCallerService.staticRpcClient = undefined; + }); + + describe('notifyThreadStatusChange()', () => { + it('should call $onThreadStatusChange on RPC client', () => { + service.notifyThreadStatusChange('session-1', 'working'); + + expect(mockRpcClient.$onThreadStatusChange).toHaveBeenCalledWith('session-1', 'working'); + }); + + it('should forward different status values', () => { + service.notifyThreadStatusChange('session-1', 'idle'); + expect(mockRpcClient.$onThreadStatusChange).toHaveBeenCalledWith('session-1', 'idle'); + + service.notifyThreadStatusChange('session-2', 'awaiting_prompt'); + expect(mockRpcClient.$onThreadStatusChange).toHaveBeenCalledWith('session-2', 'awaiting_prompt'); + }); + + it('should fall back to staticRpcClient when instance client is unavailable', () => { + Object.defineProperty(service, 'rpcClient', { value: undefined, writable: true }); + const staticClient = { $onThreadStatusChange: jest.fn().mockResolvedValue(undefined) }; + AcpThreadStatusCallerService.staticRpcClient = staticClient as any; + + service.notifyThreadStatusChange('session-1', 'working'); + + expect(staticClient.$onThreadStatusChange).toHaveBeenCalledWith('session-1', 'working'); + }); + + it('should silently do nothing when no RPC client is available', () => { + Object.defineProperty(service, 'rpcClient', { value: undefined, writable: true }); + + expect(() => service.notifyThreadStatusChange('session-1', 'idle')).not.toThrow(); + }); + + it('should silently ignore RPC call rejection', async () => { + mockRpcClient.$onThreadStatusChange.mockRejectedValue(new Error('RPC disconnected')); + + expect(() => service.notifyThreadStatusChange('session-1', 'working')).not.toThrow(); + }); + }); + + describe('staticRpcClient', () => { + it('should set and clear static client', () => { + const client = { $onThreadStatusChange: jest.fn() } as any; + AcpThreadStatusCallerService.setStaticRpcClient(client); + expect(AcpThreadStatusCallerService.staticRpcClient).toBe(client); + + AcpThreadStatusCallerService.setStaticRpcClient(undefined); + expect(AcpThreadStatusCallerService.staticRpcClient).toBeUndefined(); + }); + }); +}); diff --git a/packages/ai-native/__test__/node/acp-webmcp-handler.test.ts b/packages/ai-native/__test__/node/acp-webmcp-handler.test.ts new file mode 100644 index 0000000000..7188805092 --- /dev/null +++ b/packages/ai-native/__test__/node/acp-webmcp-handler.test.ts @@ -0,0 +1,365 @@ +import { AcpWebMcpHandler } from '../../src/node/acp/acp-webmcp-handler'; + +import type { WebMcpGroupDef, WebMcpToolResult } from '@opensumi/ide-core-common/lib/types/ai-native/acp-types'; + +const testGroupDefs: WebMcpGroupDef[] = [ + { + name: 'file', + description: 'File operations', + defaultLoaded: true, + tools: [ + { + method: '_opensumi/file/read', + description: 'Read file', + inputSchema: { type: 'object', properties: { path: { type: 'string' } } }, + }, + { + method: '_opensumi/file/write', + description: 'Write file', + inputSchema: { type: 'object', properties: { path: { type: 'string' }, content: { type: 'string' } } }, + }, + ], + }, + { + name: 'git', + description: 'Git operations', + defaultLoaded: false, + tools: [ + { method: '_opensumi/git/status', description: 'Git status', inputSchema: { type: 'object', properties: {} } }, + ], + }, +]; + +const mockCaller = { + getGroupDefinitions: jest.fn, []>(), + executeTool: jest.fn, [string, string, Record]>(), +}; + +function createHandler(logger?: { + warn?: (...args: unknown[]) => void; + debug?: (...args: unknown[]) => void; +}): AcpWebMcpHandler { + return new AcpWebMcpHandler(mockCaller as any, logger); +} + +describe('AcpWebMcpHandler', () => { + let handler: AcpWebMcpHandler; + + beforeEach(() => { + jest.clearAllMocks(); + mockCaller.getGroupDefinitions.mockResolvedValue(testGroupDefs); + handler = createHandler(); + }); + + describe('initialize()', () => { + it('should load group definitions from caller', async () => { + await handler.ensureInitialized(); + expect(mockCaller.getGroupDefinitions).toHaveBeenCalledTimes(1); + }); + + it('should auto-load default groups', async () => { + await handler.ensureInitialized(); + const result = await handler.handleExtMethod('_opensumi/webmcp/list_groups', {}); + const groups = (result as any).groups; + const fileGroup = groups.find((g: any) => g.name === 'file'); + const gitGroup = groups.find((g: any) => g.name === 'git'); + expect(fileGroup.loaded).toBe(true); + expect(gitGroup.loaded).toBe(false); + }); + + it('should count tools from default groups', async () => { + await handler.ensureInitialized(); + const result = await handler.handleExtMethod('_opensumi/webmcp/load_group', { name: 'git' }); + // file group has 2 tools (auto-loaded), git has 1 tool (just loaded) = 3 + expect((result as any).totalLoadedToolCount).toBe(3); + }); + + it('should set groupDefs to empty array on caller failure', async () => { + mockCaller.getGroupDefinitions.mockRejectedValue(new Error('RPC failed')); + const warn = jest.fn(); + const handlerWithLogger = createHandler({ warn }); + + await handlerWithLogger.ensureInitialized(); + + expect(warn).toHaveBeenCalledWith( + '[AcpWebMcpHandler] Failed to initialize group definitions:', + expect.any(Error), + ); + const result = await handlerWithLogger.handleExtMethod('_opensumi/webmcp/list_groups', {}); + expect((result as any).groups).toEqual([]); + }); + }); + + describe('handleExtMethod("_opensumi/webmcp/list_groups")', () => { + it('should return all groups with tools details', async () => { + await handler.ensureInitialized(); + const result = await handler.handleExtMethod('_opensumi/webmcp/list_groups', {}); + + expect(result).toEqual({ + groups: [ + { + name: 'file', + description: 'File operations', + defaultLoaded: true, + loaded: true, + tools: [ + { + method: '_opensumi/file/read', + description: 'Read file', + inputSchema: { type: 'object', properties: { path: { type: 'string' } } }, + }, + { + method: '_opensumi/file/write', + description: 'Write file', + inputSchema: { type: 'object', properties: { path: { type: 'string' }, content: { type: 'string' } } }, + }, + ], + }, + { + name: 'git', + description: 'Git operations', + defaultLoaded: false, + loaded: false, + tools: [ + { + method: '_opensumi/git/status', + description: 'Git status', + inputSchema: { type: 'object', properties: {} }, + }, + ], + }, + ], + }); + }); + + it('should auto-initialize on first handleExtMethod call', async () => { + // handleExtMethod calls ensureInitialized() lazily, so it auto-initializes + const result = await handler.handleExtMethod('_opensumi/webmcp/list_groups', {}); + expect((result as any).groups.length).toBeGreaterThan(0); + expect(mockCaller.getGroupDefinitions).toHaveBeenCalledTimes(1); + }); + }); + + describe('handleExtMethod("_opensumi/webmcp/load_group")', () => { + it('should load a non-default group and return its tools', async () => { + await handler.ensureInitialized(); + const result = await handler.handleExtMethod('_opensumi/webmcp/load_group', { name: 'git' }); + + expect(result).toEqual({ + group: 'git', + tools: [ + { + method: '_opensumi/git/status', + description: 'Git status', + inputSchema: { type: 'object', properties: {} }, + }, + ], + totalLoadedToolCount: 3, + }); + }); + + it('should return current state if group is already loaded', async () => { + await handler.ensureInitialized(); + // file is default-loaded, loading again should return without error + const result = await handler.handleExtMethod('_opensumi/webmcp/load_group', { name: 'file' }); + + expect(result).toEqual({ + group: 'file', + tools: [ + { + method: '_opensumi/file/read', + description: 'Read file', + inputSchema: { type: 'object', properties: { path: { type: 'string' } } }, + }, + { + method: '_opensumi/file/write', + description: 'Write file', + inputSchema: { type: 'object', properties: { path: { type: 'string' }, content: { type: 'string' } } }, + }, + ], + totalLoadedToolCount: 2, + }); + }); + + it('should return GROUP_NOT_FOUND for unknown group', async () => { + await handler.ensureInitialized(); + const result = await handler.handleExtMethod('_opensumi/webmcp/load_group', { name: 'unknown' }); + + expect(result).toEqual({ + error: 'GROUP_NOT_FOUND', + details: 'Group "unknown" not found', + }); + }); + }); + + describe('handleExtMethod("_opensumi/webmcp/unload_group")', () => { + it('should unload a loaded group and decrement tool count', async () => { + await handler.ensureInitialized(); + // First load git + await handler.handleExtMethod('_opensumi/webmcp/load_group', { name: 'git' }); + // Then unload it + const result = await handler.handleExtMethod('_opensumi/webmcp/unload_group', { name: 'git' }); + + expect(result).toEqual({ + group: 'git', + unloadedMethods: ['_opensumi/git/status'], + totalLoadedToolCount: 2, + }); + }); + + it('should return empty unloadedMethods for already-unloaded group', async () => { + await handler.ensureInitialized(); + // git is not loaded by default + const result = await handler.handleExtMethod('_opensumi/webmcp/unload_group', { name: 'git' }); + + expect(result).toEqual({ + group: 'git', + unloadedMethods: [], + totalLoadedToolCount: 2, + }); + }); + + it('should return GROUP_NOT_FOUND for unknown group', async () => { + await handler.ensureInitialized(); + const result = await handler.handleExtMethod('_opensumi/webmcp/unload_group', { name: 'nonexistent' }); + + expect(result).toEqual({ + error: 'GROUP_NOT_FOUND', + details: 'Group "nonexistent" not found', + }); + }); + + it('should decrement totalLoadedToolCount when unloading a default group', async () => { + await handler.ensureInitialized(); + const result = await handler.handleExtMethod('_opensumi/webmcp/unload_group', { name: 'file' }); + + expect(result).toEqual({ + group: 'file', + unloadedMethods: ['_opensumi/file/read', '_opensumi/file/write'], + totalLoadedToolCount: 0, + }); + }); + }); + + describe('handleExtMethod("_opensumi/{group}/{action}")', () => { + it('should execute a tool in a loaded group via caller', async () => { + await handler.ensureInitialized(); + mockCaller.executeTool.mockResolvedValue({ success: true, result: { content: 'hello' } }); + + const result = await handler.handleExtMethod('_opensumi/file/read', { path: '/tmp/test.txt' }); + + expect(mockCaller.executeTool).toHaveBeenCalledWith('file', 'read', { path: '/tmp/test.txt' }); + expect(result).toEqual({ success: true, result: { content: 'hello' } }); + }); + + it('should execute a tool in a manually loaded group', async () => { + await handler.ensureInitialized(); + await handler.handleExtMethod('_opensumi/webmcp/load_group', { name: 'git' }); + mockCaller.executeTool.mockResolvedValue({ success: true, result: { branch: 'main' } }); + + const result = await handler.handleExtMethod('_opensumi/git/status', {}); + + expect(mockCaller.executeTool).toHaveBeenCalledWith('git', 'status', {}); + expect(result).toEqual({ success: true, result: { branch: 'main' } }); + }); + + it('should return TOOL_NOT_LOADED for unloaded group', async () => { + await handler.ensureInitialized(); + // git is not loaded by default + const result = await handler.handleExtMethod('_opensumi/git/status', {}); + + expect(result).toEqual({ + success: false, + error: 'TOOL_NOT_LOADED', + details: 'Group "git" is not loaded. Call _opensumi/webmcp/load_group first.', + }); + expect(mockCaller.executeTool).not.toHaveBeenCalled(); + }); + + it('should return TOOL_NOT_LOADED after unloading a group', async () => { + await handler.ensureInitialized(); + await handler.handleExtMethod('_opensumi/webmcp/load_group', { name: 'git' }); + await handler.handleExtMethod('_opensumi/webmcp/unload_group', { name: 'git' }); + + const result = await handler.handleExtMethod('_opensumi/git/status', {}); + + expect(result).toEqual({ + success: false, + error: 'TOOL_NOT_LOADED', + details: 'Group "git" is not loaded. Call _opensumi/webmcp/load_group first.', + }); + }); + + it('should return EXECUTION_ERROR when caller throws', async () => { + await handler.ensureInitialized(); + mockCaller.executeTool.mockRejectedValue(new Error('tool crashed')); + + const result = await handler.handleExtMethod('_opensumi/file/read', { path: '/bad' }); + + expect(result).toEqual({ + success: false, + error: 'EXECUTION_ERROR', + details: 'Error: tool crashed', + }); + }); + + it('should return TOOL_NOT_FOUND for invalid method format', async () => { + await handler.ensureInitialized(); + const result = await handler.handleExtMethod('_opensumi/invalid', {}); + + expect(result).toEqual({ + success: false, + error: 'TOOL_NOT_FOUND', + details: 'Invalid method: _opensumi/invalid', + }); + }); + }); + + describe('handleExtMethod with unknown method', () => { + it('should throw method not found error for non-_opensumi methods', async () => { + await expect(handler.handleExtMethod('unknown_method', {})).rejects.toThrow('Method not found: unknown_method'); + }); + + it('should include error code -32601', async () => { + try { + await handler.handleExtMethod('unknown_method', {}); + fail('Expected error to be thrown'); + } catch (err: any) { + expect(err.code).toBe(-32601); + } + }); + }); + + describe('getCapabilityMeta()', () => { + it('should return capability metadata with groups and defaults', async () => { + await handler.ensureInitialized(); + const meta = handler.getCapabilityMeta(); + + expect(meta).toEqual({ + opensumi: { + version: '1.0', + webmcp: { + methods: ['_opensumi/webmcp/list_groups', '_opensumi/webmcp/load_group', '_opensumi/webmcp/unload_group'], + groups: ['file', 'git'], + defaultLoadedGroups: ['file'], + }, + }, + }); + }); + + it('should return empty arrays before initialize', () => { + const meta = handler.getCapabilityMeta(); + + expect(meta).toEqual({ + opensumi: { + version: '1.0', + webmcp: { + methods: ['_opensumi/webmcp/list_groups', '_opensumi/webmcp/load_group', '_opensumi/webmcp/unload_group'], + groups: [], + defaultLoadedGroups: [], + }, + }, + }); + }); + }); +}); diff --git a/packages/ai-native/__test__/node/acp/acp-spawn-config.test.ts b/packages/ai-native/__test__/node/acp/acp-spawn-config.test.ts new file mode 100644 index 0000000000..7fd89472d6 --- /dev/null +++ b/packages/ai-native/__test__/node/acp/acp-spawn-config.test.ts @@ -0,0 +1,125 @@ +import { resolveAgentSpawnConfig } from '../../../src/node/acp/acp-spawn-config'; + +describe('resolveAgentSpawnConfig', () => { + const baseConfig = { + agentId: 'test-agent', + command: '/usr/local/bin/agent', + args: ['--stdio'], + cwd: '/workspace', + }; + + const defaultProcessEnv = { PATH: '/usr/bin:/bin' }; + const defaultExecPath = '/usr/bin/node'; + + it('uses processExecPath as nodePath fallback when nothing else is set', () => { + const result = resolveAgentSpawnConfig({ + config: { ...baseConfig }, + processEnv: { ...defaultProcessEnv }, + processExecPath: defaultExecPath, + }); + expect(result.env.NODE).toBe('/usr/bin/node'); + expect(result.env.PATH).toMatch(/^\/usr\b/); + }); + + it('uses config.nodePath when set', () => { + const result = resolveAgentSpawnConfig({ + config: { ...baseConfig, nodePath: '/custom/node' }, + processEnv: { ...defaultProcessEnv }, + processExecPath: defaultExecPath, + }); + expect(result.env.NODE).toBe('/custom/node'); + expect(result.env.PATH).toMatch(/^\/custom\b/); + }); + + it('env var SUMI_ACP_NODE_PATH wins over preference', () => { + const result = resolveAgentSpawnConfig({ + config: { ...baseConfig, nodePath: '/pref/node' }, + processEnv: { ...defaultProcessEnv, SUMI_ACP_NODE_PATH: '/env/node' }, + processExecPath: defaultExecPath, + }); + expect(result.env.NODE).toBe('/env/node'); + }); + + it('env var SUMI_ACP_AGENT_PATH wins over config.command', () => { + const result = resolveAgentSpawnConfig({ + config: { ...baseConfig, command: '/reg/agent' }, + processEnv: { ...defaultProcessEnv, SUMI_ACP_AGENT_PATH: '/env/agent' }, + processExecPath: defaultExecPath, + }); + expect(result.command).toBe('/env/agent'); + }); + + it('handles Windows path correctly', () => { + // This test only makes sense on Windows where path.isAbsolute and + // path.dirname understand backslash paths + if (process.platform !== 'win32') { + return; + } + const result = resolveAgentSpawnConfig({ + config: { ...baseConfig }, + processEnv: { PATH: 'C:\\Windows\\system32' }, + processExecPath: 'C:\\Program Files\\nodejs\\node.exe', + }); + expect(result.env.NODE).toBe('C:\\Program Files\\nodejs\\node'); + expect(result.env.PATH).toContain('C:\\Program Files\\nodejs'); + expect(result.env.PATH).toContain(';'); + }); + + it('handles undefined PATH gracefully (no leading delimiter)', () => { + const result = resolveAgentSpawnConfig({ + config: { ...baseConfig }, + processEnv: {}, + processExecPath: '/usr/bin/node', + }); + expect(result.env.PATH).not.toMatch(/^[;:]/); + }); + + it('forces NODE/PATH even when config.env contains them', () => { + const result = resolveAgentSpawnConfig({ + config: { + ...baseConfig, + env: [ + { name: 'NODE', value: '/hacked/node' }, + { name: 'PATH', value: '/hacked' }, + { name: 'OTHER', value: 'keep' }, + ], + }, + processEnv: { ...defaultProcessEnv }, + processExecPath: defaultExecPath, + }); + expect(result.env.NODE).toBe('/usr/bin/node'); + expect(result.env.OTHER).toBe('keep'); + }); + + it('throws when nodePath resolves to relative path', () => { + expect(() => + resolveAgentSpawnConfig({ + config: { ...baseConfig, nodePath: 'node' }, + processEnv: { ...defaultProcessEnv }, + processExecPath: defaultExecPath, + }), + ).toThrow(/nodePath must be an absolute path/); + }); + + it('throws when processExecPath is relative and nothing else set', () => { + expect(() => + resolveAgentSpawnConfig({ + config: { ...baseConfig }, + processEnv: { ...defaultProcessEnv }, + processExecPath: 'node', + }), + ).toThrow(/nodePath must be an absolute path/); + }); + + it('converts env array to Record correctly', () => { + const result = resolveAgentSpawnConfig({ + config: { + ...baseConfig, + env: [{ name: 'FOO', value: 'bar' }], + }, + processEnv: {}, + processExecPath: '/usr/bin/node', + }); + expect(result.env.FOO).toBe('bar'); + }); +}); diff --git a/packages/ai-native/__test__/node/acp/acp-thread.test.ts b/packages/ai-native/__test__/node/acp/acp-thread.test.ts new file mode 100644 index 0000000000..d24ea3ba17 --- /dev/null +++ b/packages/ai-native/__test__/node/acp/acp-thread.test.ts @@ -0,0 +1,1134 @@ +jest.mock('@opensumi/di', () => { + const actual = jest.requireActual('@opensumi/di'); + const noopDecorator = () => () => {}; + return { + ...actual, + Injectable: () => (cls: any) => cls, + Autowired: noopDecorator, + Inject: noopDecorator, + Optional: noopDecorator, + }; +}); + +import { EventEmitter } from 'events'; + +// Mock child_process spawn +const mockSpawn = jest.fn(); +jest.mock('node:child_process', () => ({ + ChildProcess: class MockChildProcess {}, + spawn: (...args: any[]) => mockSpawn(...args), +})); + +// Mock stream/web +jest.mock('stream/web', () => ({ + ReadableStream: class MockReadableStream { + constructor() {} + }, + WritableStream: class MockWritableStream { + constructor() {} + }, +})); + +// Mock @agentclientprotocol/sdk +const mockClientSideConnection = jest.fn().mockImplementation(() => ({ + initialize: jest.fn().mockResolvedValue({ + protocolVersion: 1, + agentCapabilities: { fs: { readTextFile: true, writeTextFile: true }, terminal: true }, + }), + newSession: jest.fn().mockResolvedValue({ sessionId: 'new-session-1' }), + loadSession: jest.fn().mockResolvedValue({ sessionId: 'loaded-session-1' }), + prompt: jest.fn().mockResolvedValue({ stopReason: 'end_turn' }), + cancel: jest.fn().mockResolvedValue(undefined), + listSessions: jest.fn().mockResolvedValue({ sessions: [] }), +})); + +jest.mock('@agentclientprotocol/sdk', () => ({ + ClientSideConnection: mockClientSideConnection, + ndJsonStream: jest.fn().mockReturnValue({ readable: {}, writable: {} }), +})); + +// Mock node-pty +jest.mock('node-pty', () => ({ + spawn: jest.fn(), +})); + +import { AgentProcessConfig } from '@opensumi/ide-core-common/lib/types/ai-native/agent-types'; + +import { + AcpThread, + AcpThreadFactory, + AcpThreadFactoryProvider, + AcpThreadOptions, + AcpThreadRuntimeConfig, + AgentThreadEntry, + ThreadStatus, + ToolCallStatus, +} from '../../../src/node/acp/acp-thread'; + +// ---- Mock dependencies ---- +const mockLogger = { + log: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + verbose: jest.fn(), + warn: jest.fn(), + critical: jest.fn(), + dispose: jest.fn(), + getLevel: jest.fn(), + setLevel: jest.fn(), +}; + +const mockFileSystemHandler = { + readTextFile: jest.fn().mockResolvedValue({ content: 'file content' }), + writeTextFile: jest.fn().mockResolvedValue({}), +}; + +const mockTerminalHandler = { + createTerminal: jest.fn().mockResolvedValue({ terminalId: 'term-1' }), + getTerminalOutput: jest.fn().mockResolvedValue({ output: 'hello', truncated: false }), + waitForTerminalExit: jest.fn().mockResolvedValue({ exitCode: 0 }), + killTerminal: jest.fn().mockResolvedValue({ exitCode: 0 }), + releaseTerminal: jest.fn().mockResolvedValue({}), + releaseSessionTerminals: jest.fn().mockResolvedValue(undefined), +}; + +const mockPermissionRouting = { + routePermissionRequest: jest.fn().mockResolvedValue({ outcome: { outcome: 'allowed' } }), + registerSession: jest.fn(), + unregisterSession: jest.fn(), + setActiveSession: jest.fn(), +}; + +function createMockChildProcess(pid = 12345) { + const mock = new EventEmitter() as any; + mock.pid = pid; + mock.killed = false; + mock.exitCode = null; + mock.signalCode = null; + mock.stdio = [ + new EventEmitter(), // stdin + new EventEmitter(), // stdout + new EventEmitter(), // stderr + ]; + mock.stdio[0].writable = true; + mock.stdio[0].write = jest.fn().mockReturnValue(true); + mock.stderr = new EventEmitter(); + return mock; +} + +function createTestOptions(): AcpThreadOptions { + return { + agentId: 'test-agent', + command: 'npx', + args: ['@anthropic-ai/claude-code@latest', '--print'], + cwd: '/test/workspace', + env: [], + fileSystemHandler: mockFileSystemHandler as any, + terminalHandler: mockTerminalHandler as any, + permissionRouting: mockPermissionRouting as any, + logger: { log: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() } as any, + }; +} + +function createTestConfig(): AgentProcessConfig { + return { + agentId: 'test-agent', + command: 'npx', + args: ['@anthropic-ai/claude-code@latest', '--print'], + cwd: '/test/workspace', + }; +} + +/** Helper: extract UserMessageEntry from AgentThreadEntry */ +function getUserData(entry: AgentThreadEntry) { + return entry.type === 'user_message' ? entry.data : null; +} + +/** Helper: extract AssistantMessageEntry from AgentThreadEntry */ +function getAssistantData(entry: AgentThreadEntry) { + return entry.type === 'assistant_message' ? entry.data : null; +} + +/** Helper: extract ToolCallEntry from AgentThreadEntry */ +function getToolCallData(entry: AgentThreadEntry) { + return entry.type === 'tool_call' ? entry.data : null; +} + +describe('AcpThread', () => { + let thread: AcpThread; + let mockChildProcess: ReturnType; + + beforeEach(() => { + jest.clearAllMocks(); + mockClientSideConnection.mockClear(); + mockSpawn.mockClear(); + + mockChildProcess = createMockChildProcess(); + mockSpawn.mockImplementation(() => mockChildProcess); + + jest.spyOn(process, 'kill').mockImplementation(() => undefined as any); + + thread = new AcpThread(createTestOptions()); + Object.defineProperty(thread, 'logger', { value: mockLogger, writable: true }); + }); + + afterEach(async () => { + try { + (thread as any)._eventEmitter?.dispose(); + (thread as any)._childProcess = null; + (thread as any)._processRunning = false; + } catch {} + jest.restoreAllMocks(); + }); + + // =================================================================== + // Basic properties + // =================================================================== + describe('basic properties', () => { + it('should have a unique threadId', () => { + expect(thread.threadId).toBeDefined(); + expect(typeof thread.threadId).toBe('string'); + expect(thread.threadId.length).toBeGreaterThan(0); + }); + + it('should start with idle status', () => { + expect(thread.status).toBe('idle'); + }); + + it('should start with empty entries', () => { + expect(thread.entries).toEqual([]); + }); + + it('should start not running and not connected', () => { + expect(thread.isProcessRunning).toBe(false); + expect(thread.isConnected).toBe(false); + }); + + it('should start with empty sessionId (not nullable)', () => { + expect(thread.sessionId).toBe(''); + expect(typeof thread.sessionId).toBe('string'); + }); + + it('should start with needsReset=false', () => { + expect(thread.needsReset).toBe(false); + }); + + it('should start with null agentCapabilities', () => { + expect(thread.agentCapabilities).toBeNull(); + }); + + it('should start with initialized=false', () => { + expect(thread.initialized).toBe(false); + }); + }); + + // =================================================================== + // State machine transitions + // =================================================================== + describe('state machine transitions', () => { + it('should start as idle', () => { + expect(thread.status).toBe('idle'); + }); + + it('should transition to awaiting_prompt after newSession', async () => { + // Simulate initialize + newSession flow + (thread as any)._connected = true; + (thread as any)._connection = { + newSession: jest.fn().mockResolvedValue({ sessionId: 's1' }), + }; + (thread as any)._initialized = true; + + await thread.newSession(); + + expect(thread.status).toBe('awaiting_prompt'); + expect(thread.sessionId).toBe('s1'); + }); + + it('should transition to working during prompt', async () => { + (thread as any)._connected = true; + let resolvePrompt: ((value: any) => void) | null = null; + (thread as any)._connection = { + prompt: jest.fn().mockImplementation( + () => + new Promise((resolve) => { + resolvePrompt = resolve; + }), + ), + }; + (thread as any)._initialized = true; + + const promptPromise = thread.prompt({} as any); + + await new Promise((r) => setTimeout(r, 10)); + + expect(thread.status).toBe('working'); + + resolvePrompt!({ stopReason: 'end_turn' }); + await promptPromise; + + expect(thread.status).toBe('awaiting_prompt'); + }); + + it('should transition to disconnected on process exit', async () => { + (thread as any)._processRunning = true; + (thread as any)._connected = true; + + const exitMock = createMockChildProcess(12345); + (thread as any)._childProcess = exitMock; + + exitMock.on('exit', (code: number | null, signal: string | null) => { + (thread as any)._processRunning = false; + (thread as any)._connected = false; + (thread as any)._status = 'disconnected'; + }); + + exitMock.emit('exit', 0, null); + + expect((thread as any)._processRunning).toBe(false); + expect((thread as any)._connected).toBe(false); + expect(thread.status).toBe('disconnected'); + }); + }); + + // =================================================================== + // Message merging (chunk aggregation) — uses data wrapper pattern + // =================================================================== + describe('message merging', () => { + it('should create new user message entry on first chunk', () => { + thread.handleNotification({ + sessionId: 's1', + update: { + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: 'Hello' }, + }, + }); + + expect(thread.entries).toHaveLength(1); + expect(thread.entries[0].type).toBe('user_message'); + expect(getUserData(thread.entries[0])!.content).toBe('Hello'); + }); + + it('should append to existing user message on subsequent chunks', () => { + thread.handleNotification({ + sessionId: 's1', + update: { + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: 'Hello' }, + }, + }); + + thread.handleNotification({ + sessionId: 's1', + update: { + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: ' World' }, + }, + }); + + expect(thread.entries).toHaveLength(1); + expect(getUserData(thread.entries[0])!.content).toBe('Hello World'); + }); + + it('should create new assistant message entry for agent_message_chunk', () => { + thread.handleNotification({ + sessionId: 's1', + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'Thinking...' }, + }, + }); + + expect(thread.entries).toHaveLength(1); + expect(thread.entries[0].type).toBe('assistant_message'); + const data = getAssistantData(thread.entries[0])!; + expect(data.chunks).toHaveLength(1); + expect(data.chunks[0]).toEqual({ type: 'text', text: 'Thinking...' }); + expect(data.isComplete).toBe(false); + }); + + it('should append to last incomplete assistant message', () => { + thread.handleNotification({ + sessionId: 's1', + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'Part 1' }, + }, + }); + + thread.handleNotification({ + sessionId: 's1', + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: ' Part 2' }, + }, + }); + + expect(thread.entries).toHaveLength(1); + const data = getAssistantData(thread.entries[0])!; + const textBlock = data.chunks.find((c: any) => c.type === 'text') as any; + expect(textBlock!.text).toBe('Part 1 Part 2'); + }); + + it('should create new assistant entry after previous one is marked complete', () => { + thread.handleNotification({ + sessionId: 's1', + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'First' }, + }, + }); + + // Mark complete — no params needed + thread.markAssistantComplete(); + + // New chunk should create new entry + thread.handleNotification({ + sessionId: 's1', + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'Second' }, + }, + }); + + expect(thread.entries).toHaveLength(2); + expect(getAssistantData(thread.entries[0])!.isComplete).toBe(true); + expect(getAssistantData(thread.entries[1])!.isComplete).toBe(false); + }); + + it('should handle agent_thought_chunk separately', () => { + thread.handleNotification({ + sessionId: 's1', + update: { + sessionUpdate: 'agent_thought_chunk', + content: { type: 'text', text: 'Let me think about this...' }, + }, + }); + + expect(thread.entries).toHaveLength(1); + expect(thread.entries[0].type).toBe('assistant_message'); + const data = getAssistantData(thread.entries[0])!; + // Thought is appended as a chunk + expect(data.chunks.length).toBeGreaterThanOrEqual(1); + }); + }); + + // =================================================================== + // Tool call lifecycle — uses data wrapper pattern + // =================================================================== + describe('tool call lifecycle', () => { + it('should create tool call entry on tool_call notification', () => { + thread.handleNotification({ + sessionId: 's1', + update: { + sessionUpdate: 'tool_call', + toolCallId: 'tc-1', + toolName: 'Read', + input: { path: 'test.txt' }, + }, + } as any); + + expect(thread.entries).toHaveLength(1); + const data = getToolCallData(thread.entries[0])!; + expect(data.toolCall.toolCallId).toBe('tc-1'); + expect(data.toolCall.title).toBe('Read'); + expect(data.status).toBe('pending'); + }); + + it('should update tool call status to in_progress on tool_call_update', () => { + thread.handleNotification({ + sessionId: 's1', + update: { + sessionUpdate: 'tool_call', + toolCallId: 'tc-1', + toolName: 'Read', + }, + } as any); + + thread.handleNotification({ + sessionId: 's1', + update: { + sessionUpdate: 'tool_call_update', + toolCallId: 'tc-1', + status: 'in_progress', + }, + }); + + const data = getToolCallData(thread.entries[0])!; + expect(data.status).toBe('in_progress'); + }); + + it('should mark tool call as completed on tool_call_update with status=completed', () => { + thread.handleNotification({ + sessionId: 's1', + update: { + sessionUpdate: 'tool_call', + toolCallId: 'tc-1', + toolName: 'Read', + }, + } as any); + + thread.handleNotification({ + sessionId: 's1', + update: { + sessionUpdate: 'tool_call_update', + toolCallId: 'tc-1', + status: 'completed', + }, + }); + + const data = getToolCallData(thread.entries[0])!; + expect(data.status).toBe('completed'); + }); + + it('should mark tool call as failed on tool_call_update with status=failed', () => { + thread.handleNotification({ + sessionId: 's1', + update: { + sessionUpdate: 'tool_call', + toolCallId: 'tc-1', + toolName: 'Write', + }, + } as any); + + thread.handleNotification({ + sessionId: 's1', + update: { + sessionUpdate: 'tool_call_update', + toolCallId: 'tc-1', + status: 'failed', + }, + }); + + const data = getToolCallData(thread.entries[0])!; + expect(data.status).toBe('failed'); + }); + + it('markToolCallWaiting should update status to waiting_for_confirmation', () => { + thread.handleNotification({ + sessionId: 's1', + update: { + sessionUpdate: 'tool_call', + toolCallId: 'tc-1', + toolName: 'Write', + }, + } as any); + + thread.markToolCallWaiting('tc-1'); + + const data = getToolCallData(thread.entries[0])!; + expect(data.status).toBe('waiting_for_confirmation'); + }); + }); + + // =================================================================== + // Process initialization + // =================================================================== + describe('process initialization', () => { + it('ensureSdkConnection should only start process once if already running', async () => { + (thread as any)._childProcess = mockChildProcess; + (thread as any)._processRunning = true; + (thread as any)._connected = true; + (thread as any)._connection = { initialize: jest.fn() }; + + await (thread as any).ensureSdkConnection(); + + expect(mockSpawn).not.toHaveBeenCalled(); + }); + + it('should clean up stale process reference before starting new one', async () => { + mockChildProcess.killed = true; + (thread as any)._childProcess = mockChildProcess; + (thread as any)._processRunning = true; + expect((thread as any).isProcessAlive()).toBe(false); + + (thread as any)._childProcess = null; + (thread as any)._processRunning = false; + + const newMock = createMockChildProcess(99999); + mockSpawn.mockReturnValue(newMock); + + await (thread as any).startProcess(); + + expect(mockSpawn).toHaveBeenCalled(); + expect((thread as any)._processRunning).toBe(true); + expect((thread as any)._childProcess).toBe(newMock); + }); + + it('should accept AgentProcessConfig in initialize()', async () => { + (thread as any)._childProcess = mockChildProcess; + (thread as any)._processRunning = true; + (thread as any)._connected = true; + const mockInitialize = jest.fn().mockResolvedValue({ + protocolVersion: 1, + agentCapabilities: { fs: { readTextFile: true } }, + }); + (thread as any)._connection = { initialize: mockInitialize }; + + const config: AgentProcessConfig = createTestConfig(); + const result = await thread.initialize(config); + + expect(mockInitialize).toHaveBeenCalled(); + expect(thread.initialized).toBe(true); + }); + }); + + // =================================================================== + // Dispose cleanup + // =================================================================== + describe('dispose()', () => { + it('should clear connection reference', async () => { + (thread as any)._connected = true; + (thread as any)._connection = {}; + + await thread.dispose(); + + expect((thread as any)._connection).toBeNull(); + expect((thread as any)._connected).toBe(false); + }); + + it('should clear pending permission requests', async () => { + (thread as any)._pendingPermissionRequests.set('req-1', { + resolve: jest.fn(), + reject: jest.fn(), + }); + + await thread.dispose(); + + expect((thread as any)._pendingPermissionRequests.size).toBe(0); + }); + + it('should kill the process', async () => { + (thread as any)._childProcess = mockChildProcess; + (thread as any)._processRunning = true; + + const killSpy = jest.spyOn(thread as any, 'killProcess').mockImplementation(async () => { + (thread as any)._childProcess = null; + (thread as any)._processRunning = false; + }); + + await thread.dispose(); + + expect(killSpy).toHaveBeenCalled(); + expect((thread as any)._processRunning).toBe(false); + expect((thread as any)._childProcess).toBeNull(); + }); + }); + + // =================================================================== + // reset() — spec: does NOT clear _initialized + // =================================================================== + describe('reset()', () => { + it('should clear all entries', () => { + thread.addUserMessage('Hello'); + expect(thread.entries).toHaveLength(1); + + thread.reset(); + + expect(thread.entries).toEqual([]); + }); + + it('should clear sessionId and needsReset', () => { + (thread as any)._sessionId = 's1'; + (thread as any)._needsReset = true; + + thread.reset(); + + expect(thread.sessionId).toBe(''); + expect(thread.needsReset).toBe(false); + }); + + it('should NOT clear initialized flag (thread remains reusable)', () => { + (thread as any)._initialized = true; + + thread.reset(); + + expect((thread as any)._initialized).toBe(true); + }); + + it('should reset status to idle', () => { + (thread as any)._status = 'working'; + + thread.reset(); + + expect(thread.status).toBe('idle'); + }); + + it('should clear pending permission requests', () => { + (thread as any)._pendingPermissionRequests.set('req-1', { + resolve: jest.fn(), + reject: jest.fn(), + }); + + thread.reset(); + + expect((thread as any)._pendingPermissionRequests.size).toBe(0); + }); + }); + + // =================================================================== + // Entry manipulation — data wrapper pattern + // =================================================================== + describe('addUserMessage()', () => { + it('should create a user message entry and add to entries', () => { + const entry = thread.addUserMessage('Hello, AI!'); + + expect(entry.content).toBe('Hello, AI!'); + expect(thread.entries).toHaveLength(1); + expect(thread.entries[0].type).toBe('user_message'); + expect(getUserData(thread.entries[0])!).toBe(entry); + }); + + it('should generate a unique id for each message', () => { + const e1 = thread.addUserMessage('First'); + const e2 = thread.addUserMessage('Second'); + + expect(e1.id).not.toBe(e2.id); + }); + + it('should set timestamp', () => { + const entry = thread.addUserMessage('Test'); + expect(entry.timestamp).toBeGreaterThan(0); + }); + }); + + describe('markAssistantComplete()', () => { + it('should mark last assistant entry as complete (no params)', () => { + thread.handleNotification({ + sessionId: 's1', + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'Draft' }, + }, + }); + + const data = getAssistantData(thread.entries[0])!; + expect(data.isComplete).toBe(false); + + // No params — finds last assistant entry automatically + thread.markAssistantComplete(); + + expect(data.isComplete).toBe(true); + }); + + it('should transition status to awaiting_prompt', () => { + thread.handleNotification({ + sessionId: 's1', + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'Answer' }, + }, + }); + + (thread as any)._status = 'working'; + + thread.markAssistantComplete(); + + expect(thread.status).toBe('awaiting_prompt'); + }); + + it('should do nothing if no assistant entry exists', () => { + expect(thread.entries).toEqual([]); + thread.markAssistantComplete(); + expect(thread.entries).toEqual([]); + }); + + it('should emit entry_updated event', () => { + const events: any[] = []; + thread.onEvent((e) => events.push(e)); + + thread.handleNotification({ + sessionId: 's1', + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'Draft' }, + }, + }); + + thread.markAssistantComplete(); + + const updatedEvent = events.find((e) => e.type === 'entry_updated'); + expect(updatedEvent).toBeDefined(); + expect(updatedEvent.entry.type).toBe('assistant_message'); + }); + }); + + // =================================================================== + // handleNotification — public method + // =================================================================== + describe('handleNotification', () => { + it('should be a public method on the instance', () => { + expect(typeof thread.handleNotification).toBe('function'); + }); + + it('should handle available_commands_update without creating entries', () => { + thread.handleNotification({ + sessionId: 's1', + update: { + sessionUpdate: 'available_commands_update', + commands: [], + }, + } as any); + + expect(thread.entries).toEqual([]); + }); + + it('should create/replace plan entry on plan notification', () => { + thread.handleNotification({ + sessionId: 's1', + update: { + sessionUpdate: 'plan', + content: { type: 'text', text: 'Plan: 1. Read file 2. Edit' }, + }, + } as any); + + expect(thread.entries).toHaveLength(1); + expect(thread.entries[0].type).toBe('plan'); + + // Second plan should replace first + thread.handleNotification({ + sessionId: 's1', + update: { + sessionUpdate: 'plan', + content: { type: 'text', text: 'Updated plan: 1. Read 2. Write 3. Test' }, + }, + } as any); + + expect(thread.entries).toHaveLength(1); + expect(thread.entries[0].type).toBe('plan'); + }); + + it('should transition to working on tool_call notification', () => { + (thread as any)._status = 'awaiting_prompt'; + + thread.handleNotification({ + sessionId: 's1', + update: { + sessionUpdate: 'tool_call', + toolCallId: 'tc-1', + toolName: 'Read', + }, + } as any); + + expect(thread.status).toBe('working'); + }); + }); + + // =================================================================== + // Event emission — granular events + // =================================================================== + describe('onEvent', () => { + it('should emit status_changed events', () => { + const events: any[] = []; + thread.onEvent((e) => events.push(e)); + + thread.setStatus('working'); + + const statusEvent = events.find((e) => e.type === 'status_changed'); + expect(statusEvent).toBeDefined(); + expect(statusEvent.status).toBe('working'); + }); + + it('should emit entry_added events when entries are appended', () => { + const events: any[] = []; + thread.onEvent((e) => events.push(e)); + + thread.addUserMessage('Hello'); + + const addedEvent = events.find((e) => e.type === 'entry_added'); + expect(addedEvent).toBeDefined(); + expect(addedEvent.entry.type).toBe('user_message'); + }); + + it('should emit entry_updated events when entries are modified', () => { + const events: any[] = []; + thread.onEvent((e) => events.push(e)); + + thread.addUserMessage('Hello'); + thread.markToolCallWaiting('tc-x'); // no-op but tests mechanism + + // Simulate an update via notification + thread.handleNotification({ + sessionId: 's1', + update: { + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: 'Hello' }, + }, + }); + // Append to existing → fires entry_updated + thread.handleNotification({ + sessionId: 's1', + update: { + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: ' World' }, + }, + }); + + const updatedEvent = events.find((e) => e.type === 'entry_updated'); + expect(updatedEvent).toBeDefined(); + }); + + it('should emit session_notification events when notification received via client', () => { + const events: any[] = []; + thread.onEvent((e) => events.push(e)); + + thread.handleNotification({ + sessionId: 's1', + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'Hello' }, + }, + }); + + // Fire session_notification event directly (simulates what client impl does) + (thread as any).fireEvent({ + type: 'session_notification', + notification: { + sessionId: 's1', + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'Hello' }, + }, + }, + }); + + const notifEvent = events.find((e) => e.type === 'session_notification'); + expect(notifEvent).toBeDefined(); + }); + + it('should NOT emit entries_changed events (replaced by entry_added/entry_updated)', () => { + const events: any[] = []; + thread.onEvent((e) => events.push(e)); + + thread.addUserMessage('Hello'); + thread.markAssistantComplete(); + + const entriesChangedEvent = events.find((e) => e.type === 'entries_changed'); + expect(entriesChangedEvent).toBeUndefined(); + }); + }); + + // =================================================================== + // ensureInitialized guard + // =================================================================== + describe('ensureInitialized guard', () => { + it('should throw if not initialized when calling newSession', async () => { + (thread as any)._connection = null; + + await expect(thread.newSession()).rejects.toThrow('AcpThread not initialized'); + }); + + it('should throw if not initialized when calling prompt', async () => { + (thread as any)._connection = null; + + await expect(thread.prompt({} as any)).rejects.toThrow('AcpThread not initialized'); + }); + + it('should throw if not initialized when calling loadSession', async () => { + (thread as any)._connection = null; + + await expect(thread.loadSession({ sessionId: 's1' } as any)).rejects.toThrow('AcpThread not initialized'); + }); + + it('should throw if not initialized when calling listSessions', async () => { + (thread as any)._connection = null; + + await expect(thread.listSessions()).rejects.toThrow('AcpThread not initialized'); + }); + }); + + // =================================================================== + // respondToToolCall — spec: (toolCallId, allowed: boolean) + // =================================================================== + describe('respondToToolCall()', () => { + it('should mark tool call as completed when allowed=true', () => { + thread.handleNotification({ + sessionId: 's1', + update: { + sessionUpdate: 'tool_call', + toolCallId: 'tc-1', + toolName: 'Write', + }, + } as any); + + thread.respondToToolCall('tc-1', true); + + const data = getToolCallData(thread.entries[0])!; + expect(data.status).toBe('completed'); + }); + + it('should mark tool call as rejected when allowed=false', () => { + thread.handleNotification({ + sessionId: 's1', + update: { + sessionUpdate: 'tool_call', + toolCallId: 'tc-1', + toolName: 'Write', + }, + } as any); + + thread.respondToToolCall('tc-1', false); + + const data = getToolCallData(thread.entries[0])!; + expect(data.status).toBe('rejected'); + }); + + it('should emit entry_updated event', () => { + const events: any[] = []; + thread.onEvent((e) => events.push(e)); + + thread.handleNotification({ + sessionId: 's1', + update: { + sessionUpdate: 'tool_call', + toolCallId: 'tc-1', + toolName: 'Write', + }, + } as any); + + thread.respondToToolCall('tc-1', true); + + const updatedEvent = events.find((e) => e.type === 'entry_updated'); + expect(updatedEvent).toBeDefined(); + }); + + it('should do nothing for non-existent tool call ID', () => { + expect(() => { + thread.respondToToolCall('nonexistent', true); + }).not.toThrow(); + }); + }); + + // =================================================================== + // setError — new method (spec) + // =================================================================== + describe('setError()', () => { + it('should set status to errored', () => { + const error = new Error('Something went wrong'); + thread.setError(error); + + expect(thread.status).toBe('errored'); + }); + + it('should emit status_changed and error events', () => { + const events: any[] = []; + thread.onEvent((e) => events.push(e)); + + const error = new Error('Test error'); + thread.setError(error); + + const statusEvent = events.find((e) => e.type === 'status_changed'); + expect(statusEvent).toBeDefined(); + expect(statusEvent.status).toBe('errored'); + + const errorEvent = events.find((e) => e.type === 'error'); + expect(errorEvent).toBeDefined(); + expect(errorEvent.error).toBe(error); + }); + }); + + // =================================================================== + // State accessors (spec) + // =================================================================== + describe('state accessors', () => { + it('getStatus() should return current status', () => { + expect(thread.getStatus()).toBe('idle'); + (thread as any)._status = 'working'; + expect(thread.getStatus()).toBe('working'); + }); + + it('getEntries() should return readonly entries', () => { + thread.addUserMessage('Hello'); + const entries = thread.getEntries(); + expect(entries).toHaveLength(1); + expect(entries[0].type).toBe('user_message'); + }); + }); + + // =================================================================== + // AcpThreadFactory — DI factory for creating AcpThread instances + // =================================================================== + describe('AcpThreadFactory', () => { + const provider = AcpThreadFactoryProvider as any; + + it('AcpThreadFactoryProvider should have correct token', () => { + expect(provider.token).toBeDefined(); + expect(typeof provider.token).toBe('symbol'); + }); + + it('AcpThreadFactoryProvider should have useFactory function', () => { + expect(typeof provider.useFactory).toBe('function'); + }); + + it('factory should create an AcpThread instance with correct dependencies', () => { + // Simulate Injector.get() behavior + const mockInjector = { + get: jest.fn((token: symbol) => { + if (token === (provider.useFactory as any).toString().match(/AcpFileSystemHandlerToken/)?.[0]) { + return mockFileSystemHandler; + } + return mockTerminalHandler; + }), + }; + + // Directly invoke with mocked injector-like object + const factoryFn = provider.useFactory({ + get: (token: any) => + // Match by checking what token is requested + mockFileSystemHandler, + }); + + // Since we can't easily match tokens, test the returned function directly + const runtimeConfig: AcpThreadRuntimeConfig = { + agentId: 'test-agent', + command: 'npx', + args: ['@anthropic-ai/claude-code@latest', '--print'], + cwd: '/test/workspace', + env: [], + }; + + const threadInstance = factoryFn('test-session-1', runtimeConfig); + + expect(threadInstance).toBeInstanceOf(AcpThread); + expect(threadInstance.threadId).toBeDefined(); + expect(threadInstance.status).toBe('idle'); + }); + + it('factory should return a function with correct type signature', () => { + const factoryFn = provider.useFactory({ + get: () => mockFileSystemHandler, + }); + + expect(typeof factoryFn).toBe('function'); + + // Verify it's a factory function + const typedFactory: AcpThreadFactory = factoryFn; + const thread = typedFactory('session-2', { + agentId: 'test-agent', + command: 'node', + args: ['agent.js'], + cwd: '/tmp', + }); + + expect(thread).toBeInstanceOf(AcpThread); + }); + + it('created thread should receive runtime config parameters', () => { + const factoryFn = provider.useFactory({ + get: () => mockFileSystemHandler, + }); + + const threadInstance = factoryFn('test-session-3', { + command: 'npx', + args: ['agent'], + cwd: '/test', + env: [{ name: 'FOO', value: 'bar' }], + }); + + // Verify runtime config options are set + expect((threadInstance as any).options.command).toBe('npx'); + expect((threadInstance as any).options.args).toEqual(['agent']); + expect((threadInstance as any).options.cwd).toBe('/test'); + expect((threadInstance as any).options.env).toEqual([{ name: 'FOO', value: 'bar' }]); + }); + }); +}); diff --git a/packages/ai-native/__test__/node/permission-routing.test.ts b/packages/ai-native/__test__/node/permission-routing.test.ts new file mode 100644 index 0000000000..c6c2285811 --- /dev/null +++ b/packages/ai-native/__test__/node/permission-routing.test.ts @@ -0,0 +1,188 @@ +jest.mock('@opensumi/di', () => { + const actual = jest.requireActual('@opensumi/di'); + const noopDecorator = () => () => {}; + return { + ...actual, + Injectable: () => (cls: any) => cls, + Autowired: noopDecorator, + Inject: noopDecorator, + Optional: noopDecorator, + }; +}); + +import { AcpPermissionCallerService } from '../../src/node/acp/acp-permission-caller.service'; +import { PermissionRoutingService, PermissionRoutingServiceToken } from '../../src/node/acp/permission-routing.service'; + +const mockLogger = { + log: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + verbose: jest.fn(), + warn: jest.fn(), + critical: jest.fn(), + dispose: jest.fn(), + getLevel: jest.fn(), + setLevel: jest.fn(), +}; + +const mockCallerService = { + requestPermission: jest.fn(), + cancelRequest: jest.fn(), +}; + +const baseRequest = { + sessionId: 'sess-1', + toolCall: { + toolCallId: 'tc-1', + title: 'Test Tool', + kind: 'read', + status: 'pending', + } as any, + options: [{ optionId: 'allow_once', name: 'Allow Once', kind: 'allow_once' as const }], +}; + +function createService(): PermissionRoutingService { + const service = new PermissionRoutingService(); + Object.defineProperty(service, 'permissionCallerService', { value: mockCallerService, writable: true }); + Object.defineProperty(service, 'logger', { value: mockLogger, writable: true }); + return service; +} + +describe('PermissionRoutingService', () => { + let service: PermissionRoutingService; + + beforeEach(() => { + jest.clearAllMocks(); + service = createService(); + }); + + describe('session registration', () => { + it('should register a session', () => { + service.registerSession('sess-1'); + service.registerSession('sess-2'); + + // Verify by routing - should use the registered session + mockCallerService.requestPermission.mockResolvedValue({ outcome: { outcome: 'selected', optionId: 'opt-1' } }); + + // Registered session should be routable + service.routePermissionRequest(baseRequest, 'sess-1'); + expect(mockCallerService.requestPermission).toHaveBeenCalledWith(baseRequest, 'sess-1'); + }); + + it('should unregister a session', () => { + service.registerSession('sess-1'); + service.unregisterSession('sess-1'); + + mockCallerService.requestPermission.mockResolvedValue({ outcome: { outcome: 'selected', optionId: 'opt-1' } }); + + // Unregistered session should fall back (no active session = cancelled) + // Since no active session, returns cancelled + }); + + it('should not affect other sessions when unregistering one', () => { + service.registerSession('sess-1'); + service.registerSession('sess-2'); + service.unregisterSession('sess-1'); + + // sess-2 should still be routable (as active fallback if set) + }); + }); + + describe('routePermissionRequest - routing strategy', () => { + beforeEach(() => { + mockCallerService.requestPermission.mockResolvedValue({ + outcome: { outcome: 'selected', optionId: 'allow_once' }, + }); + }); + + it('should route to registered sessionId', async () => { + service.registerSession('sess-1'); + + const result = await service.routePermissionRequest(baseRequest, 'sess-1'); + + expect(mockCallerService.requestPermission).toHaveBeenCalledWith(baseRequest, 'sess-1'); + expect(result.outcome.outcome).toBe('selected'); + }); + + it('should return cancelled when sessionId is not registered', async () => { + service.registerSession('sess-1'); + + const result = await service.routePermissionRequest(baseRequest, 'sess-other'); + + expect(result.outcome.outcome).toBe('cancelled'); + expect(mockCallerService.requestPermission).not.toHaveBeenCalled(); + }); + + it('should return cancelled when no session is available', async () => { + const result = await service.routePermissionRequest(baseRequest, 'sess-none'); + + expect(result.outcome.outcome).toBe('cancelled'); + expect(mockCallerService.requestPermission).not.toHaveBeenCalled(); + }); + + it('should return cancelled when no sessions registered and no active session', async () => { + service.registerSession('sess-1'); + service.unregisterSession('sess-1'); + + const result = await service.routePermissionRequest(baseRequest, 'sess-1'); + + expect(result.outcome.outcome).toBe('cancelled'); + expect(mockCallerService.requestPermission).not.toHaveBeenCalled(); + }); + }); + + describe('concurrent requests', () => { + it('should handle concurrent requests independently', async () => { + service.registerSession('sess-1'); + service.registerSession('sess-2'); + + // Simulate different response times + mockCallerService.requestPermission + .mockImplementationOnce(async (params, sessionId) => { + await new Promise((r) => setTimeout(r, 50)); + return { outcome: { outcome: 'selected', optionId: `opt-${sessionId}` } }; + }) + .mockImplementationOnce(async (params, sessionId) => ({ + outcome: { outcome: 'selected', optionId: `opt-${sessionId}` }, + })); + + const [result1, result2] = await Promise.all([ + service.routePermissionRequest(baseRequest, 'sess-1'), + service.routePermissionRequest(baseRequest, 'sess-2'), + ]); + + // Each request should have its own result based on its sessionId + expect(result1.outcome.outcome).toBe('selected'); + expect(result2.outcome.outcome).toBe('selected'); + // Both calls should have been made independently + expect(mockCallerService.requestPermission).toHaveBeenCalledTimes(2); + }); + + it('should not cross-contaminate results between sessions', async () => { + service.registerSession('sess-a'); + service.registerSession('sess-b'); + + mockCallerService.requestPermission + .mockImplementationOnce(async (_params, sessionId: string) => { + // Simulate sess-a taking longer + await new Promise((r) => setTimeout(r, 30)); + return sessionId === 'sess-a' + ? { outcome: { outcome: 'selected', optionId: 'allow' } } + : { outcome: { outcome: 'cancelled' } }; + }) + .mockImplementationOnce(async (_params, sessionId: string) => + sessionId === 'sess-b' + ? { outcome: { outcome: 'selected', optionId: 'allow' } } + : { outcome: { outcome: 'cancelled' } }, + ); + + const [resultA, resultB] = await Promise.all([ + service.routePermissionRequest(baseRequest, 'sess-a'), + service.routePermissionRequest(baseRequest, 'sess-b'), + ]); + + expect((resultA.outcome as any).optionId).toBe('allow'); + expect((resultB.outcome as any).optionId).toBe('allow'); + }); + }); +}); diff --git a/packages/ai-native/__tests__/node/acp/cli-agent-process-manager.test.ts b/packages/ai-native/__tests__/node/acp/cli-agent-process-manager.test.ts deleted file mode 100644 index dd806d6bf4..0000000000 --- a/packages/ai-native/__tests__/node/acp/cli-agent-process-manager.test.ts +++ /dev/null @@ -1,506 +0,0 @@ -import { EventEmitter } from 'events'; - -// Mock child_process module before importing the class under test -const mockSpawn = jest.fn(); - -jest.mock('child_process', () => ({ - spawn: (...args: any[]) => mockSpawn(...args), -})); - -import { CliAgentProcessManager } from '../../../src/node/acp/cli-agent-process-manager'; - -// Mock dependencies -const mockLogger = { - log: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - debug: jest.fn(), - info: jest.fn(), -}; - -jest.mock('@opensumi/di', () => ({ - Injectable: () => jest.fn(), - Autowired: () => jest.fn(), -})); - -jest.mock('@opensumi/ide-core-node', () => ({ - INodeLogger: Symbol('INodeLogger'), -})); - -// Helper: create a mock ChildProcess with controllable behavior -function createMockChildProcess(opts?: { pid?: number; killed?: boolean; exitCode?: number | null }): any { - const mock = new EventEmitter() as any; - mock.pid = opts?.pid ?? 12345; - mock.killed = opts?.killed ?? false; - mock.exitCode = opts?.exitCode ?? null; - mock.signalCode = null; - mock.stdin = { write: jest.fn(), on: jest.fn(), pipe: jest.fn() }; - mock.stdout = new EventEmitter(); - mock.stderr = new EventEmitter(); - mock.kill = jest.fn().mockReturnValue(true); - mock.stdio = [mock.stdin, mock.stdout, mock.stderr]; - return mock; -} - -describe('CliAgentProcessManager', () => { - let manager: CliAgentProcessManager; - let mockProcessKill: jest.SpyInstance; - - const defaultCommand = '/usr/bin/agent'; - const defaultArgs = ['--mode', 'cli']; - const defaultEnv = { KEY: 'value' }; - const defaultCwd = '/tmp/workspace'; - - beforeEach(() => { - jest.useFakeTimers(); - mockSpawn.mockClear(); - - mockProcessKill = jest.spyOn(process, 'kill').mockImplementation(() => true as any); - - manager = new CliAgentProcessManager(); - (manager as any).logger = mockLogger; - }); - - afterEach(() => { - jest.useRealTimers(); - jest.restoreAllMocks(); - }); - - // ==================== startAgent ==================== - - describe('startAgent', () => { - it('should create a new process when none exists', async () => { - const mockChild = createMockChildProcess(); - mockSpawn.mockReturnValue(mockChild); - - const startPromise = manager.startAgent(defaultCommand, defaultArgs, defaultEnv, defaultCwd); - jest.advanceTimersByTime(100); - const result = await startPromise; - - expect(mockSpawn).toHaveBeenCalledWith(defaultCommand, defaultArgs, { - cwd: defaultCwd, - stdio: ['pipe', 'pipe', 'pipe'], - detached: false, - shell: false, - env: expect.objectContaining({ KEY: 'value' }), - }); - expect(result.processId).toBe('12345'); - expect(result.stdout).toBe(mockChild.stdio[1]); - expect(result.stdin).toBe(mockChild.stdio[0]); - }); - - it('should reject with wrapped error when command not found (ENOENT)', async () => { - const mockChild = createMockChildProcess(); - mockSpawn.mockReturnValue(mockChild); - - const promise = manager.startAgent('nonexistent', [], {}, '/tmp'); - - // Emit error event (simulates spawn failing immediately) - const err: any = new Error('spawn ENOENT'); - err.code = 'ENOENT'; - mockChild.emit('error', err); - - jest.advanceTimersByTime(100); - - await expect(promise).rejects.toThrow( - 'Command not found: nonexistent. Please ensure the CLI agent is installed.', - ); - }); - - it('should reject with wrapped error when permission denied (EACCES)', async () => { - const mockChild = createMockChildProcess(); - mockSpawn.mockReturnValue(mockChild); - - const promise = manager.startAgent('/bin/restricted', [], {}, '/tmp'); - - const err: any = new Error('spawn EACCES'); - err.code = 'EACCES'; - mockChild.emit('error', err); - - jest.advanceTimersByTime(100); - - await expect(promise).rejects.toThrow('Permission denied when executing: /bin/restricted'); - }); - - it('should reject when child process has no PID', async () => { - const mockChild = createMockChildProcess({ pid: 0 }); - mockSpawn.mockReturnValue(mockChild); - - const promise = manager.startAgent(defaultCommand, defaultArgs, defaultEnv, defaultCwd); - jest.advanceTimersByTime(100); - - await expect(promise).rejects.toThrow('Failed to get PID for agent process'); - }); - - it('should reuse existing process when config is the same', async () => { - const mockChild = createMockChildProcess(); - mockSpawn.mockReturnValue(mockChild); - - const p1 = manager.startAgent(defaultCommand, defaultArgs, defaultEnv, defaultCwd); - jest.advanceTimersByTime(100); - const result1 = await p1; - - mockSpawn.mockClear(); - const p2 = manager.startAgent(defaultCommand, defaultArgs, defaultEnv, defaultCwd); - const result2 = await p2; - - expect(mockSpawn).not.toHaveBeenCalled(); - expect(result2.processId).toBe(result1.processId); - }); - - it('should clean up exited process and create new one', async () => { - const mockChild1 = createMockChildProcess(); - mockSpawn.mockReturnValue(mockChild1); - - const p1 = manager.startAgent(defaultCommand, defaultArgs, defaultEnv, defaultCwd); - jest.advanceTimersByTime(100); - await p1; - - // Simulate process exit - mockChild1.killed = true; - mockChild1.exitCode = 0; - mockChild1.emit('exit', 0, null); - - const mockChild2 = createMockChildProcess({ pid: 99999 }); - mockSpawn.mockReturnValue(mockChild2); - mockSpawn.mockClear(); - - const p2 = manager.startAgent(defaultCommand, defaultArgs, defaultEnv, defaultCwd); - jest.advanceTimersByTime(100); - const result = await p2; - - expect(result.processId).toBe('99999'); - }); - - it('should use SUMI_ACP_AGENT_PATH env var to override command', async () => { - const originalEnv = process.env.SUMI_ACP_AGENT_PATH; - process.env.SUMI_ACP_AGENT_PATH = '/custom/agent/path'; - - const mockChild = createMockChildProcess(); - mockSpawn.mockReturnValue(mockChild); - - const p = manager.startAgent(defaultCommand, defaultArgs, defaultEnv, defaultCwd); - jest.advanceTimersByTime(100); - await p; - - expect(mockSpawn).toHaveBeenCalledWith('/custom/agent/path', defaultArgs, expect.any(Object)); - - if (originalEnv !== undefined) { - process.env.SUMI_ACP_AGENT_PATH = originalEnv; - } else { - delete process.env.SUMI_ACP_AGENT_PATH; - } - }); - - it('should set NODE and PATH in env based on SUMI_ACP_NODE_PATH', async () => { - const originalNodePath = process.env.SUMI_ACP_NODE_PATH; - process.env.SUMI_ACP_NODE_PATH = '/opt/node/v18/bin/node'; - - const mockChild = createMockChildProcess(); - mockSpawn.mockReturnValue(mockChild); - - const p = manager.startAgent(defaultCommand, defaultArgs, defaultEnv, defaultCwd); - jest.advanceTimersByTime(100); - await p; - - const spawnOpts = mockSpawn.mock.calls[0][2]; - expect(spawnOpts.env.NODE).toBe('/opt/node/v18/bin/node'); - expect(spawnOpts.env.PATH).toContain('/opt/node/v18'); - - if (originalNodePath !== undefined) { - process.env.SUMI_ACP_NODE_PATH = originalNodePath; - } else { - delete process.env.SUMI_ACP_NODE_PATH; - } - }); - }); - - // ==================== isRunning ==================== - - describe('isRunning', () => { - it('should return false when no process exists', () => { - expect(manager.isRunning()).toBe(false); - }); - - it('should return false when process is killed', () => { - const mockChild = createMockChildProcess({ killed: true }); - (manager as any).currentProcess = mockChild; - - expect(manager.isRunning()).toBe(false); - }); - - it('should return false when process has exit code', () => { - const mockChild = createMockChildProcess({ exitCode: 1 }); - (manager as any).currentProcess = mockChild; - - expect(manager.isRunning()).toBe(false); - }); - - it('should return false when process has no pid', () => { - const mockChild = createMockChildProcess({ pid: 0 }); - (manager as any).currentProcess = mockChild; - - expect(manager.isRunning()).toBe(false); - }); - - it('should return true when process exists and is alive', () => { - const mockChild = createMockChildProcess(); - (manager as any).currentProcess = mockChild; - - expect(manager.isRunning()).toBe(true); - }); - - it('should return false when process.kill(pid, 0) throws', () => { - const mockChild = createMockChildProcess(); - (manager as any).currentProcess = mockChild; - - mockProcessKill.mockImplementation(() => { - throw new Error('kill ESRCH'); - }); - - expect(manager.isRunning()).toBe(false); - }); - }); - - // ==================== getExitCode ==================== - - describe('getExitCode', () => { - it('should return null when no process exists', () => { - expect(manager.getExitCode()).toBeNull(); - }); - - it('should return exit code when process has one', () => { - const mockChild = createMockChildProcess({ exitCode: 42 }); - (manager as any).currentProcess = mockChild; - - expect(manager.getExitCode()).toBe(42); - }); - - it('should return null when process has no exit code yet', () => { - const mockChild = createMockChildProcess(); - (manager as any).currentProcess = mockChild; - - expect(manager.getExitCode()).toBeNull(); - }); - }); - - // ==================== listRunningAgents ==================== - - describe('listRunningAgents', () => { - it('should return empty array when no process', () => { - expect(manager.listRunningAgents()).toEqual([]); - }); - - it('should return singleton ID when process is running', () => { - const mockChild = createMockChildProcess(); - (manager as any).currentProcess = mockChild; - - expect(manager.listRunningAgents()).toEqual(['singleton-agent-process']); - }); - }); - - // ==================== stopAgent ==================== - - describe('stopAgent', () => { - it('should return immediately when no process exists', async () => { - await manager.stopAgent(); - expect(mockProcessKill).not.toHaveBeenCalled(); - }); - - it('should send SIGTERM to process group and wait for graceful exit', async () => { - const mockChild = createMockChildProcess(); - (manager as any).currentProcess = mockChild; - - const stopPromise = manager.stopAgent(); - - expect(mockProcessKill).toHaveBeenCalledWith(-12345, 'SIGTERM'); - - mockChild.emit('exit', 0, null); - - await stopPromise; - }); - - it('should force kill after graceful shutdown timeout', async () => { - const mockChild = createMockChildProcess(); - (manager as any).currentProcess = mockChild; - - const stopPromise = manager.stopAgent(); - - expect(mockProcessKill).toHaveBeenCalledWith(-12345, 'SIGTERM'); - - jest.advanceTimersByTime(5000); - - expect(mockProcessKill).toHaveBeenCalledWith(-12345, 'SIGKILL'); - - await stopPromise; - }); - }); - - // ==================== killAgent ==================== - - describe('killAgent', () => { - it('should send SIGKILL to process group immediately', async () => { - const mockChild = createMockChildProcess(); - (manager as any).currentProcess = mockChild; - - const killPromise = manager.killAgent(); - - expect(mockProcessKill).toHaveBeenCalledWith(-12345, 'SIGKILL'); - - mockChild.emit('exit', null, 'SIGKILL'); - - await killPromise; - }); - - it('should resolve after timeout even if process does not exit', async () => { - const mockChild = createMockChildProcess(); - (manager as any).currentProcess = mockChild; - - const killPromise = manager.killAgent(); - - jest.advanceTimersByTime(3000); - - await killPromise; - - expect((manager as any).currentProcess).toBeNull(); - }); - - it('should resolve immediately when no process', async () => { - await manager.killAgent(); - expect(mockProcessKill).not.toHaveBeenCalled(); - }); - }); - - // ==================== killAllAgents ==================== - - describe('killAllAgents', () => { - it('should delegate to forceKillInternal', async () => { - const mockChild = createMockChildProcess(); - (manager as any).currentProcess = mockChild; - - const killPromise = manager.killAllAgents(); - - expect(mockProcessKill).toHaveBeenCalledWith(-12345, 'SIGKILL'); - - mockChild.emit('exit', null, 'SIGKILL'); - - await killPromise; - }); - }); - - // ==================== killProcessGroup ==================== - - describe('killProcessGroup', () => { - it('should try process group kill first', () => { - const mockChild = createMockChildProcess(); - (manager as any).currentProcess = mockChild; - - (manager as any).killProcessGroup(12345, 'SIGTERM'); - - expect(mockProcessKill).toHaveBeenNthCalledWith(1, -12345, 'SIGTERM'); - }); - - it('should fallback to single process kill when group kill fails', () => { - const mockChild = createMockChildProcess(); - (manager as any).currentProcess = mockChild; - - let callCount = 0; - mockProcessKill.mockImplementation(() => { - callCount++; - if (callCount === 1) { - throw new Error('ESRCH'); - } - return true as any; - }); - - const result = (manager as any).killProcessGroup(12345, 'SIGTERM'); - - expect(mockProcessKill).toHaveBeenNthCalledWith(1, -12345, 'SIGTERM'); - expect(mockProcessKill).toHaveBeenNthCalledWith(2, 12345, 'SIGTERM'); - expect(result).toBe(true); - }); - - it('should return false when both kills fail', () => { - const mockChild = createMockChildProcess(); - (manager as any).currentProcess = mockChild; - - mockProcessKill.mockImplementation(() => { - throw new Error('ESRCH'); - }); - - const result = (manager as any).killProcessGroup(12345, 'SIGTERM'); - - expect(result).toBe(false); - }); - }); - - // ==================== handleProcessExit ==================== - - describe('handleProcessExit', () => { - it('should clear all state on exit', async () => { - const mockChild = createMockChildProcess(); - (manager as any).currentProcess = mockChild; - (manager as any).currentCommand = defaultCommand; - (manager as any).currentCwd = defaultCwd; - - // Directly call the private method - (manager as any).handleProcessExit(1, null); - - expect((manager as any).currentProcess).toBeNull(); - expect((manager as any).currentCommand).toBeNull(); - expect((manager as any).currentCwd).toBeNull(); - }); - - it('should clear state even with null code and signal', () => { - const mockChild = createMockChildProcess(); - (manager as any).currentProcess = mockChild; - (manager as any).currentCommand = defaultCommand; - (manager as any).currentCwd = defaultCwd; - - (manager as any).handleProcessExit(null, null); - - expect((manager as any).currentProcess).toBeNull(); - expect((manager as any).currentCommand).toBeNull(); - expect((manager as any).currentCwd).toBeNull(); - }); - }); - - // ==================== wrapError ==================== - - describe('wrapError', () => { - it('should wrap ENOENT error', () => { - const err: any = new Error('spawn ENOENT'); - err.code = 'ENOENT'; - - const wrapped = (manager as any).wrapError(err, 'my-agent'); - - expect(wrapped.message).toBe('Command not found: my-agent. Please ensure the CLI agent is installed.'); - }); - - it('should wrap EACCES error', () => { - const err: any = new Error('spawn EACCES'); - err.code = 'EACCES'; - - const wrapped = (manager as any).wrapError(err, 'my-agent'); - - expect(wrapped.message).toBe('Permission denied when executing: my-agent'); - }); - - it('should wrap EPERM error', () => { - const err: any = new Error('spawn EPERM'); - err.code = 'EPERM'; - - const wrapped = (manager as any).wrapError(err, 'my-agent'); - - expect(wrapped.message).toBe('Permission denied when executing: my-agent'); - }); - - it('should return original error for other codes', () => { - const err = new Error('some other error'); - - const wrapped = (manager as any).wrapError(err, 'my-agent'); - - expect(wrapped).toBe(err); - }); - }); -}); diff --git a/packages/ai-native/package.json b/packages/ai-native/package.json index ff209caffa..f194b055b7 100644 --- a/packages/ai-native/package.json +++ b/packages/ai-native/package.json @@ -60,8 +60,8 @@ "react-highlight": "^0.15.0", "tiktoken": "1.0.12", "web-tree-sitter": "0.22.6", - "zod": "^3.23.8", - "zod-to-json-schema": "^3.24.1" + "zod": "^3.25.0 || ^4.0.0", + "zod-to-json-schema": "^3.25.0" }, "devDependencies": { "@opensumi/ide-core-browser": "workspace:*" 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 index 10acb0b3cc..d8703c846a 100644 --- a/packages/ai-native/src/browser/acp/acp-permission-rpc.service.ts +++ b/packages/ai-native/src/browser/acp/acp-permission-rpc.service.ts @@ -39,6 +39,7 @@ export class AcpPermissionRpcService extends RPCService implements IAcpPermissio // Call the browser-side permission bridge service const decision = await this.permissionBridgeService.showPermissionDialog({ requestId: params.requestId, + sessionId: params.sessionId, title: params.title, kind: params.kind, content: params.content, diff --git a/packages/ai-native/src/browser/acp/acp-thread-status-rpc.service.ts b/packages/ai-native/src/browser/acp/acp-thread-status-rpc.service.ts new file mode 100644 index 0000000000..3a42d78ef8 --- /dev/null +++ b/packages/ai-native/src/browser/acp/acp-thread-status-rpc.service.ts @@ -0,0 +1,24 @@ +import { Autowired, Injectable } from '@opensumi/di'; +import { RPCService } from '@opensumi/ide-connection/lib/common/rpc-service'; +import { IAcpThreadStatusService } from '@opensumi/ide-core-common'; + +import { IChatManagerService } from '../../common'; +import { ChatModel } from '../chat/chat-model'; + +/** + * Browser-side RPC service for receiving thread status notifications from Node. + * Called from the Node layer via RPC to push status updates to the browser. + */ +@Injectable() +export class AcpThreadStatusRpcService extends RPCService implements IAcpThreadStatusService { + @Autowired(IChatManagerService) + private chatManagerService: any; + + async $onThreadStatusChange(sessionId: string, status: string): Promise { + const lookupKey = sessionId.startsWith('acp:') ? sessionId : `acp:${sessionId}`; + const model = this.chatManagerService.getSession?.(lookupKey) as ChatModel | undefined; + if (model && typeof model.setThreadStatus === 'function') { + model.setThreadStatus(status as any); + } + } +} diff --git a/packages/ai-native/src/browser/acp/acp-webmcp-rpc.service.ts b/packages/ai-native/src/browser/acp/acp-webmcp-rpc.service.ts new file mode 100644 index 0000000000..0e1133707f --- /dev/null +++ b/packages/ai-native/src/browser/acp/acp-webmcp-rpc.service.ts @@ -0,0 +1,28 @@ +import { Autowired, Injectable } from '@opensumi/di'; +import { RPCService } from '@opensumi/ide-connection/lib/common/rpc-service'; +import { WebMcpGroupRegistryToken } from '@opensumi/ide-core-common/lib/types/ai-native/acp-types'; + +import type { WebMcpGroupRegistry } from './webmcp-group-registry'; +import type { + IAcpWebMcpBridgeService, + WebMcpGroupDef, + WebMcpToolResult, +} from '@opensumi/ide-core-common/lib/types/ai-native/acp-types'; + +/** + * Browser-side RPC service for WebMCP bridge calls. + * Receives RPC calls from the Node layer and delegates to the group registry. + */ +@Injectable() +export class AcpWebMcpRpcService extends RPCService implements IAcpWebMcpBridgeService { + @Autowired(WebMcpGroupRegistryToken) + private readonly registry: WebMcpGroupRegistry; + + async $getGroupDefinitions(): Promise { + return this.registry.getGroupDefinitions(); + } + + async $executeTool(group: string, tool: string, params: Record): Promise { + return this.registry.executeTool(group, tool, params); + } +} diff --git a/packages/ai-native/src/browser/acp/build-agent-process-config.ts b/packages/ai-native/src/browser/acp/build-agent-process-config.ts new file mode 100644 index 0000000000..2996f75c09 --- /dev/null +++ b/packages/ai-native/src/browser/acp/build-agent-process-config.ts @@ -0,0 +1,49 @@ +import { EnvVariable, McpServer } from '@opensumi/ide-core-common/lib/types/ai-native/acp-types'; +import { AgentProcessConfig } from '@opensumi/ide-core-common/lib/types/ai-native/agent-types'; + +/** + * Pure function: merge agent registration defaults with user preferences + * into the final AgentProcessConfig. Called on browser side before RPC. + */ +export function buildAcpAgentProcessConfig(input: { + agentId: string; + registration: { + command: string; + args: string[]; + env?: EnvVariable[]; + cwd: string; + }; + userPreferences: { + nodePath: string; + agents: Record }>; + }; + mcpServers?: McpServer[]; +}): AgentProcessConfig { + const override = input.userPreferences.agents[input.agentId] ?? {}; + const config: AgentProcessConfig = { + agentId: input.agentId, + command: override.command ?? input.registration.command, + args: override.args ?? input.registration.args, + env: mergeEnv(input.registration.env, override.env), + cwd: input.registration.cwd, + nodePath: input.userPreferences.nodePath || undefined, + }; + if (input.mcpServers) { + config.mcpServers = input.mcpServers; + } + return config; +} + +function mergeEnv(base?: EnvVariable[], override?: Record): EnvVariable[] | undefined { + if (!base && !override) { + return undefined; + } + const map = new Map(); + for (const v of base ?? []) { + map.set(v.name, v.value); + } + for (const [k, v] of Object.entries(override ?? {})) { + map.set(k, v); + } + return Array.from(map, ([name, value]) => ({ name, value })); +} diff --git a/packages/ai-native/src/browser/acp/components/AcpChatHistory.tsx b/packages/ai-native/src/browser/acp/components/AcpChatHistory.tsx index 1037068365..f102928f8c 100644 --- a/packages/ai-native/src/browser/acp/components/AcpChatHistory.tsx +++ b/packages/ai-native/src/browser/acp/components/AcpChatHistory.tsx @@ -4,14 +4,39 @@ import React, { FC, memo, useCallback, useEffect, useRef, useState } from 'react import { Icon, Input, Loading, Popover, PopoverPosition, PopoverTriggerType, getIcon } from '@opensumi/ide-components'; import { localize } from '@opensumi/ide-core-browser'; import { EnhanceIcon } from '@opensumi/ide-core-browser/lib/components/ai-native'; +import { ThreadStatus } from '@opensumi/ide-core-common'; import styles from '../../components/acp/chat-history.module.less'; +const threadStatusIcon: Record = { + idle: 'disconnect', + working: 'loading', + awaiting_prompt: 'disconnect', + auth_required: 'disconnect', + errored: 'error', + disconnected: 'disconnect', +}; + +function renderThreadStatusIcon(status: ThreadStatus | undefined, loading: boolean, testId: string) { + const effectiveStatus: ThreadStatus = status ?? (loading ? 'working' : 'idle'); + const iconName = threadStatusIcon[effectiveStatus] || threadStatusIcon.idle; + return ( + + ); +} + export interface IChatHistoryItem { id: string; title: string; updatedAt: number; loading: boolean; + threadStatus?: ThreadStatus; + hasPendingPermission?: boolean; } export interface IChatHistoryProps { @@ -21,6 +46,7 @@ export interface IChatHistoryProps { className?: string; historyLoading?: boolean; disabled?: boolean; + pendingPermissionBadge?: number; onNewChat: () => void; onHistoryItemSelect: (item: IChatHistoryItem) => void; onHistoryItemDelete?: (item: IChatHistoryItem) => void; @@ -47,6 +73,7 @@ const AcpChatHistory: FC = memo( historyLoading, disabled, className, + pendingPermissionBadge, }) => { const [historyTitleEditable, setHistoryTitleEditable] = useState<{ [key: string]: boolean; @@ -160,19 +187,40 @@ const AcpChatHistory: FC = memo( const renderHistoryItem = useCallback( (item: IChatHistoryItem) => (
handleHistoryItemSelect(item)} > + {item.hasPendingPermission}
- {item.loading ? ( - - ) : ( - + {!item.hasPendingPermission && + renderThreadStatusIcon( + item.threadStatus, + item.loading, + `acp-thread-status-${item.id}-${item.threadStatus || 'default'}`, + )} + {item.hasPendingPermission && ( + )} + {/* + [{item.threadStatus ?? (item.loading ? 'working' : 'idle')}] + */} {!historyTitleEditable?.[item.id] ? ( - {item.title} + {item.title || 'Untitled'} ) : ( = memo( const filteredList = historyList .slice(-MAX_HISTORY_LIST) .reverse() - .filter((item) => item.title && item.title.includes(searchValue)); + .filter((item) => item.title !== undefined && item.title.includes(searchValue)); const groupedHistoryList = formatHistory(filteredList); @@ -216,7 +264,10 @@ const AcpChatHistory: FC = memo( value={searchValue} onChange={handleSearchChange} /> -
+
{historyLoading ? (
@@ -251,11 +302,19 @@ const AcpChatHistory: FC = memo( getPopupContainer={getPopupContainer} onVisibleChange={onHistoryPopoverVisibleChange} > -
- +
+
+ + {pendingPermissionBadge && pendingPermissionBadge > 0 ? ( + + {pendingPermissionBadge > 99 ? '99+' : pendingPermissionBadge} + + ) : null} +
}, [isExpand]); return ( -
+
{isShowOptions && (
(IMessageService); const workspaceService = useInjectable(IWorkspaceService); const quickPick = useInjectable(QuickPickService); + const permissionBridgeService = useInjectable(AcpPermissionBridgeService); const [historyList, setHistoryList] = React.useState([]); const [currentTitle, setCurrentTitle] = React.useState(''); const [historyLoading, setHistoryLoading] = React.useState(false); const [sessionSwitching, setSessionSwitching] = React.useState(false); + const [pendingPermissionBadge, setPendingPermissionBadge] = React.useState(0); const isMultiRoot = workspaceService.isMultiRootWorkspaceOpened; + const subscribedSessionIdsRef = React.useRef>(new Set()); + const toDisposeRef = React.useRef(new DisposableCollection()); + const [currentWorkspaceDir, setCurrentWorkspaceDir] = React.useState(getCachedWorkspaceDir()); // Sync state when cache is updated externally (e.g. by session provider on first init) @@ -111,6 +118,21 @@ export function AcpChatViewHeader({ const getHistoryList = React.useCallback(async () => { const sessions = aiChatService.getSessions(); + // Subscribe to thread status changes for any new sessions + for (const session of sessions) { + const model = session as ChatModel; + if (!subscribedSessionIdsRef.current.has(model.sessionId)) { + subscribedSessionIdsRef.current.add(model.sessionId); + toDisposeRef.current.push( + model.onThreadStatusChange((status) => { + setHistoryList((prev) => + prev.map((item) => (item.id === model.sessionId ? { ...item, threadStatus: status } : item)), + ); + }), + ); + } + } + // 当前会话标题 const currentMessages = aiChatService.sessionModel?.history.getMessages() || []; const latestUserMessage = [...currentMessages].find((m) => m.role === ChatMessageRole.User); @@ -138,6 +160,8 @@ export function AcpChatViewHeader({ title: sessionTitle, updatedAt, loading: false, + threadStatus: (session as ChatModel).threadStatus, + hasPendingPermission: permissionBridgeService.hasPendingForSession(session.sessionId), }; }), ); @@ -162,9 +186,25 @@ export function AcpChatViewHeader({ React.useEffect(() => { getHistoryList(); - const toDispose = new DisposableCollection(); + const toDispose = toDisposeRef.current; let previousMessageChangeDisposable: IDisposable | undefined; + const refreshBadge = () => { + setPendingPermissionBadge(permissionBridgeService.getPendingCountExcludingActive()); + }; + refreshBadge(); + toDispose.push( + permissionBridgeService.onPendingCountChange(() => { + refreshBadge(); + getHistoryList(); + }), + ); + toDispose.push( + permissionBridgeService.onActiveSessionChange(() => { + refreshBadge(); + }), + ); + toDispose.push( aiChatService.onChangeSession(() => { getHistoryList(); @@ -189,6 +229,7 @@ export function AcpChatViewHeader({ return () => { toDispose.dispose(); + subscribedSessionIdsRef.current.clear(); }; }, [aiChatService]); @@ -201,6 +242,7 @@ export function AcpChatViewHeader({ historyList={historyList} historyLoading={historyLoading} disabled={sessionSwitching} + pendingPermissionBadge={pendingPermissionBadge} onNewChat={handleNewChat} onHistoryItemSelect={handleHistoryItemSelect} onHistoryItemDelete={() => {}} diff --git a/packages/ai-native/src/browser/acp/index.ts b/packages/ai-native/src/browser/acp/index.ts index 78c39d5487..49964d5cbf 100644 --- a/packages/ai-native/src/browser/acp/index.ts +++ b/packages/ai-native/src/browser/acp/index.ts @@ -1,5 +1,17 @@ export { AcpPermissionHandler } from './permission.handler'; export { AcpPermissionBridgeService, ShowPermissionDialogParams } from './permission-bridge.service'; export { AcpPermissionRpcService } from './acp-permission-rpc.service'; +export { AcpThreadStatusRpcService } from './acp-thread-status-rpc.service'; export { PermissionDialog, PermissionDialogProps } from './permission-dialog.view'; export { default as PermissionDialogStyles } from './permission-dialog.module.less'; +export { WebMcpGroupRegistry, WebMcpGroupRegistration, WebMcpToolExecute } from './webmcp-group-registry'; +export { AcpWebMcpRpcService } from './acp-webmcp-rpc.service'; +export { + tryGetService, + classifyError, + safeErrorMessage, + successResult, + errorResult, + serviceUnavailableResult, +} from './webmcp-utils'; +export type { ErrorCode, WebMcpToolResult as BrowserWebMcpToolResult } from './webmcp-utils'; diff --git a/packages/ai-native/src/browser/acp/permission-bridge.service.ts b/packages/ai-native/src/browser/acp/permission-bridge.service.ts index e646d67798..e662e5798a 100644 --- a/packages/ai-native/src/browser/acp/permission-bridge.service.ts +++ b/packages/ai-native/src/browser/acp/permission-bridge.service.ts @@ -9,6 +9,7 @@ import type { PermissionOption, PermissionOptionKind } from '@opensumi/ide-core- export interface ShowPermissionDialogParams { requestId: string; + sessionId: string; title: string; kind?: string; content?: string; @@ -31,7 +32,7 @@ export class AcpPermissionBridgeService { string, { resolve: (decision: PermissionDecision) => void; - timeout: NodeJS.Timeout; + timeout: NodeJS.Timeout | undefined; } >(); @@ -47,6 +48,49 @@ export class AcpPermissionBridgeService { decision: PermissionDecision; }> = this.onPermissionResult.event; + // --------------------------------------------------------------------------- + // Active session tracking + // --------------------------------------------------------------------------- + + private activeSessionId: string | undefined; + + private readonly onActiveSessionChangeEmitter = new Emitter(); + readonly onActiveSessionChange: Event = this.onActiveSessionChangeEmitter.event; + + // --------------------------------------------------------------------------- + // Pending permission index (session-scoped) + // --------------------------------------------------------------------------- + + private pendingBySessionId = new Map>(); + + private readonly onPendingCountChangeEmitter = new Emitter(); + readonly onPendingCountChange: Event = this.onPendingCountChangeEmitter.event; + + /** + * Maps requestId → sessionId so we can clean up the pending index + * when handleUserDecision/handleDialogClose fires. + */ + private requestIdToSessionId = new Map(); + + /** + * Set the currently active session. + * Fires event to notify UI to re-render session-scoped dialogs. + */ + setActiveSession(sessionId: string | undefined): void { + if (this.activeSessionId === sessionId) { + return; + } + this.activeSessionId = sessionId; + this.onActiveSessionChangeEmitter.fire(sessionId); + } + + /** + * Get the currently active session ID. + */ + getActiveSession(): string | undefined { + return this.activeSessionId; + } + /** * Show permission dialog and wait for user response */ @@ -75,19 +119,24 @@ export class AcpPermissionBridgeService { this.activeDialogs.set(requestId, dialogProps); + // Register in pending index + this.requestIdToSessionId.set(requestId, params.sessionId); + let pendingSet = this.pendingBySessionId.get(params.sessionId); + if (!pendingSet) { + pendingSet = new Set(); + this.pendingBySessionId.set(params.sessionId, pendingSet); + } + pendingSet.add(requestId); + this.onPendingCountChangeEmitter.fire(); + // Emit event to show dialog this.onPermissionRequest.fire(params); - // Set up timeout - const timeout = setTimeout(() => { - this.handleDialogClose(requestId); - }, params.timeout); - - // Wait for decision + // Wait for decision (no auto-timeout) return new Promise((resolve) => { this.pendingDecisions.set(requestId, { resolve, - timeout, + timeout: undefined, }); }); } @@ -101,7 +150,9 @@ export class AcpPermissionBridgeService { return; } - clearTimeout(pending.timeout); + if (pending.timeout) { + clearTimeout(pending.timeout); + } this.pendingDecisions.delete(requestId); const always = optionKind === 'allow_always' || optionKind === 'reject_always'; @@ -113,6 +164,9 @@ export class AcpPermissionBridgeService { always, }; + // Clean up pending index + this.cleanupPendingIndex(requestId); + this.activeDialogs.delete(requestId); this.onPermissionResult.fire({ requestId, decision }); pending.resolve(decision); @@ -127,16 +181,41 @@ export class AcpPermissionBridgeService { return; } - clearTimeout(pending.timeout); + if (pending.timeout) { + clearTimeout(pending.timeout); + } this.pendingDecisions.delete(requestId); const decision: PermissionDecision = { type: 'timeout' }; + // Clean up pending index + this.cleanupPendingIndex(requestId); + this.activeDialogs.delete(requestId); this.onPermissionResult.fire({ requestId, decision }); pending.resolve(decision); } + /** + * Clean up the pending index for a given requestId. + * Removes the request from the session set, prunes empty sets, + * deletes the reverse mapping, and fires the count-change event. + */ + private cleanupPendingIndex(requestId: string): void { + const sessionId = this.requestIdToSessionId.get(requestId); + if (sessionId) { + const sessionSet = this.pendingBySessionId.get(sessionId); + if (sessionSet) { + sessionSet.delete(requestId); + if (sessionSet.size === 0) { + this.pendingBySessionId.delete(sessionId); + } + } + this.requestIdToSessionId.delete(requestId); + this.onPendingCountChangeEmitter.fire(); + } + } + /** * Cancel a pending permission request */ @@ -157,4 +236,65 @@ export class AcpPermissionBridgeService { getActiveDialogs(): PermissionDialogProps[] { return Array.from(this.activeDialogs.values()); } + + /** + * Clear all dialogs and pending decisions for a given session. + * Called when a session is permanently deleted (clearSessionModel). + */ + clearSessionDialogs(sessionId: string): void { + const prefix = `${sessionId}:`; + // Clear active dialogs + for (const [requestId, dialog] of this.activeDialogs.entries()) { + if (requestId === sessionId || requestId.startsWith(prefix)) { + this.activeDialogs.delete(requestId); + } + } + // Clear pending decisions (resolve as cancelled) + for (const [requestId, pending] of this.pendingDecisions.entries()) { + if (requestId === sessionId || requestId.startsWith(prefix)) { + if (pending.timeout) { + clearTimeout(pending.timeout); + } + this.pendingDecisions.delete(requestId); + const decision: PermissionDecision = { type: 'cancelled' }; + this.onPermissionResult.fire({ requestId, decision }); + pending.resolve(decision); + } + } + // Drop pending index entry for this session + if (this.pendingBySessionId.delete(sessionId)) { + this.onPendingCountChangeEmitter.fire(); + } + // Also clean up the requestIdToSessionId map for this session's requests + let cleanedReverse = false; + for (const [rid, sid] of this.requestIdToSessionId.entries()) { + if (sid === sessionId) { + this.requestIdToSessionId.delete(rid); + cleanedReverse = true; + } + } + if (cleanedReverse) { + this.onPendingCountChangeEmitter.fire(); + } + } + + /** + * Count of pending permission requests across all sessions EXCEPT the active one. + */ + getPendingCountExcludingActive(): number { + let count = 0; + for (const [sid, set] of this.pendingBySessionId) { + if (sid !== this.activeSessionId) { + count += set.size; + } + } + return count; + } + + /** + * Whether a specific session has any pending permission requests. + */ + hasPendingForSession(sessionId: string): boolean { + return (this.pendingBySessionId.get(sessionId)?.size ?? 0) > 0; + } } diff --git a/packages/ai-native/src/browser/acp/permission-dialog-container.tsx b/packages/ai-native/src/browser/acp/permission-dialog-container.tsx index 697228747f..fb8eba5f26 100644 --- a/packages/ai-native/src/browser/acp/permission-dialog-container.tsx +++ b/packages/ai-native/src/browser/acp/permission-dialog-container.tsx @@ -50,6 +50,21 @@ class PermissionDialogManager { return [...this.dialogs]; } + getDialogsForSession(sessionId: string | undefined): DialogState[] { + if (!sessionId) { + return []; + } + return this.dialogs.filter((d) => d.params.sessionId === sessionId); + } + + clearDialogsForSession(sessionId: string | undefined): void { + if (!sessionId) { + return; + } + this.dialogs = this.dialogs.filter((d) => d.params.sessionId !== sessionId); + this.notifyListeners(); + } + subscribe(listener: (dialogs: DialogState[]) => void) { this.listeners.push(listener); return () => { @@ -141,6 +156,7 @@ export class AcpPermissionDialogContribution implements ComponentContribution { const AcpPermissionDialogContainer: React.FC = () => { // 状态管理 const [dialogs, setDialogs] = useState([]); + const [activeSessionId, setActiveSessionId] = useState(); const [focusedIndex, setFocusedIndex] = useState(0); const functionComponentDialogManager = useInjectable(PermissionDialogManager); @@ -162,12 +178,26 @@ const AcpPermissionDialogContainer: React.FC = () => { return unsubscribe; }, []); + // Subscribe to active session changes + useEffect(() => { + const disposable = permissionBridgeService.onActiveSessionChange((sessionId) => { + setActiveSessionId(sessionId); + setFocusedIndex(0); + }); + // Initialize with current session + setActiveSessionId(permissionBridgeService.getActiveSession()); + return () => disposable.dispose(); + }, []); + + // Filter dialogs for active session only + const sessionDialogs = functionComponentDialogManager.getDialogsForSession(activeSessionId); + // 键盘导航处理函数(使用 useCallback 优化性能) const handleKeyboardNavigation = useCallback( (e: KeyboardEvent) => { - const options = dialogs[0]?.params.options || []; + const options = sessionDialogs[0]?.params.options || []; - if (dialogs.length === 0) { + if (sessionDialogs.length === 0) { return; } @@ -205,12 +235,12 @@ const AcpPermissionDialogContainer: React.FC = () => { handleDialogClose(); } }, - [dialogs, focusedIndex], + [sessionDialogs, focusedIndex], ); // 组件更新:动态添加/移除键盘监听 useEffect(() => { - if (dialogs.length > 0) { + if (sessionDialogs.length > 0) { window.addEventListener('keydown', handleKeyboardNavigation); // 添加焦点 if (containerRef.current) { @@ -223,16 +253,16 @@ const AcpPermissionDialogContainer: React.FC = () => { return () => { window.removeEventListener('keydown', handleKeyboardNavigation); }; - }, [dialogs.length, handleKeyboardNavigation]); + }, [sessionDialogs.length, handleKeyboardNavigation]); // 处理用户选择 const handleDialogSelect = useCallback( (_optionId: string) => { - if (dialogs.length === 0) { + if (sessionDialogs.length === 0) { return; } - const requestId = dialogs[0].requestId; - const params = dialogs[0].params; + const requestId = sessionDialogs[0].requestId; + const params = sessionDialogs[0].params; // Find the selected option to get its kind const selectedOption = params.options.find((opt) => opt.optionId === _optionId); @@ -249,27 +279,27 @@ const AcpPermissionDialogContainer: React.FC = () => { // Close dialog functionComponentDialogManager.removeDialog(requestId); }, - [dialogs, permissionBridgeService], + [sessionDialogs, permissionBridgeService], ); // 处理对话框关闭 const handleDialogClose = useCallback(() => { - if (dialogs.length === 0) { + if (sessionDialogs.length === 0) { return; } - const requestId = dialogs[0].requestId; + const requestId = sessionDialogs[0].requestId; // Notify the permission bridge service that the dialog was cancelled permissionBridgeService.handleDialogClose(requestId); // Close dialog functionComponentDialogManager.removeDialog(requestId); - }, [dialogs, permissionBridgeService]); + }, [sessionDialogs, permissionBridgeService]); // 如果没有对话框,返回null - if (dialogs.length === 0) { + if (sessionDialogs.length === 0) { return null; } - const currentDialog = dialogs[0]; + const currentDialog = sessionDialogs[0]; const params = currentDialog.params; const smartTitle = getSmartTitle(params); const shouldShowDescription = diff --git a/packages/ai-native/src/browser/acp/webmcp-file-tools.registry.ts b/packages/ai-native/src/browser/acp/webmcp-file-tools.registry.ts new file mode 100644 index 0000000000..0a6ed97355 --- /dev/null +++ b/packages/ai-native/src/browser/acp/webmcp-file-tools.registry.ts @@ -0,0 +1,788 @@ +/** + * WebMCP tool registry for file management. + * + * Registers browser-side tools on `navigator.modelContext` that allow an external + * AI agent to interact with the file system — reading, writing, listing, creating, + * deleting, moving, and copying files. + * + * Tools follow the naming convention: file_ + * + * PHASE 1: Register core file operations with hand-crafted schemas. + * Phase 2: Later, add more granular tools and refine descriptions. + */ +import { IDisposable, Injector } from '@opensumi/di'; +import { AppConfig, path } from '@opensumi/ide-core-browser'; +import { URI } from '@opensumi/ide-core-common'; +import { IFileServiceClient } from '@opensumi/ide-file-service'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function tryGetService(container: Injector, token: symbol): T | null { + try { + return container.get(token) as T; + } catch { + return null; + } +} + +function classifyError(err: unknown): string { + if (typeof err === 'object' && err !== null) { + const name = (err as Error).name || ''; + if (name.includes('Timeout') || name.includes('timeout')) {return 'RPC_TIMEOUT';} + if (name.includes('Injector') || name.includes('DI')) {return 'DI_ERROR';} + if (name.includes('Permission') || name.includes('denied')) {return 'PERMISSION_DENIED';} + if (name.includes('Abort')) {return 'ABORTED';} + if (name.includes('FileNotFound') || name.includes('ENOENT')) {return 'FILE_NOT_FOUND';} + if (name.includes('FileExists') || name.includes('EEXIST')) {return 'FILE_EXISTS';} + } + return 'EXECUTION_ERROR'; +} + +function safeErrorMessage(err: unknown): string { + const msg = err instanceof Error ? err.message : String(err); + return msg + .replace(/[A-Za-z_]*token[A-Za-z_]*[:=\s]+["']?[A-Za-z0-9+/=]+["']?/gi, '[REDACTED]') + .replace(/[A-Za-z_]*key[A-Za-z_]*[:=\s]+["']?[A-Za-z0-9+/=]+["']?/gi, '[REDACTED]') + .substring(0, 200); +} + +function resolveWorkspacePath(workspaceDir: string, relativePath: string): string { + return path.join(workspaceDir, relativePath); +} + +function toUri(filePath: string): string { + return URI.file(filePath).toString(); +} + +// --------------------------------------------------------------------------- +// Registry +// --------------------------------------------------------------------------- + +export function registerFileWebMCPTools(container: Injector): IDisposable { + const ensureModelContext = () => { + if (!navigator.modelContext) { + throw new Error('navigator.modelContext is not available'); + } + }; + ensureModelContext(); + + const ctx = navigator.modelContext!; + const controller = new AbortController(); + + // ----- file_getWorkspaceRoot ----- + ctx.registerTool( + { + name: 'file_getWorkspaceRoot', + description: + 'Get the absolute path of the current workspace root directory. Use this to understand the base path for relative file operations.', + inputSchema: { + type: 'object', + properties: {}, + }, + execute: async () => { + const appConfig = tryGetService(container, AppConfig); + if (!appConfig) { + return { + success: false, + error: 'SERVICE_UNAVAILABLE', + details: 'AppConfig not registered in DI container', + }; + } + try { + return { + success: true, + result: { + workspaceRoot: appConfig.workspaceDir, + }, + }; + } catch (err) { + return { + success: false, + error: classifyError(err), + details: safeErrorMessage(err), + }; + } + }, + }, + { signal: controller.signal }, + ); + + // ----- file_read ----- + ctx.registerTool( + { + name: 'file_read', + description: + 'Read the contents of a file. Returns the file content as text. Use relative paths from the workspace root.', + inputSchema: { + type: 'object', + properties: { + path: { + type: 'string', + description: 'The relative path of the file to read, from the workspace root.', + }, + }, + required: ['path'], + }, + execute: async (args: { path: string }) => { + if (!args.path) { + return { + success: false, + error: 'INVALID_INPUT', + details: 'path is required', + }; + } + const appConfig = tryGetService(container, AppConfig); + if (!appConfig || !appConfig.workspaceDir) { + return { + success: false, + error: 'SERVICE_UNAVAILABLE', + details: 'No workspace directory available', + }; + } + const fileService = tryGetService(container, IFileServiceClient); + if (!fileService) { + return { + success: false, + error: 'SERVICE_UNAVAILABLE', + details: 'IFileServiceClient not registered in DI container', + }; + } + try { + const absolutePath = resolveWorkspacePath(appConfig.workspaceDir, args.path); + const uri = toUri(absolutePath); + const fileStat = await fileService.getFileStat(uri); + if (!fileStat) { + return { + success: false, + error: 'FILE_NOT_FOUND', + details: `File not found: ${args.path}`, + }; + } + if (fileStat.isDirectory) { + return { + success: false, + error: 'IS_DIRECTORY', + details: `Path is a directory, not a file: ${args.path}`, + }; + } + const result = await fileService.readFile(uri); + const content = result.content.toString(); + return { + success: true, + result: { + path: args.path, + content, + size: content.length, + }, + }; + } catch (err) { + return { + success: false, + error: classifyError(err), + details: safeErrorMessage(err), + }; + } + }, + }, + { signal: controller.signal }, + ); + + // ----- file_write ----- + ctx.registerTool( + { + name: 'file_write', + description: + 'Write content to a file. Creates the file if it does not exist, overwrites if it does. Creates parent directories automatically.', + inputSchema: { + type: 'object', + properties: { + path: { + type: 'string', + description: 'The relative path of the file to write, from the workspace root.', + }, + content: { + type: 'string', + description: 'The content to write to the file.', + }, + }, + required: ['path', 'content'], + }, + execute: async (args: { path: string; content: string }) => { + if (!args.path || args.content === undefined) { + return { + success: false, + error: 'INVALID_INPUT', + details: 'path and content are required', + }; + } + const appConfig = tryGetService(container, AppConfig); + if (!appConfig || !appConfig.workspaceDir) { + return { + success: false, + error: 'SERVICE_UNAVAILABLE', + details: 'No workspace directory available', + }; + } + const fileService = tryGetService(container, IFileServiceClient); + if (!fileService) { + return { + success: false, + error: 'SERVICE_UNAVAILABLE', + details: 'IFileServiceClient not registered in DI container', + }; + } + try { + const absolutePath = resolveWorkspacePath(appConfig.workspaceDir, args.path); + const uri = toUri(absolutePath); + const existingStat = await fileService.getFileStat(uri); + + let result: any; + if (existingStat) { + result = await fileService.setContent(existingStat, args.content); + } else { + result = await fileService.createFile(uri, { content: args.content }); + } + return { + success: true, + result: { + path: args.path, + written: true, + size: args.content.length, + }, + }; + } catch (err) { + return { + success: false, + error: classifyError(err), + details: safeErrorMessage(err), + }; + } + }, + }, + { signal: controller.signal }, + ); + + // ----- file_list ----- + ctx.registerTool( + { + name: 'file_list', + description: + 'List the contents of a directory. Returns an array of file/directory entries with metadata. Use "." for the workspace root.', + inputSchema: { + type: 'object', + properties: { + path: { + type: 'string', + description: + 'The relative path of the directory to list, from the workspace root. Use "." for workspace root.', + }, + }, + required: ['path'], + }, + execute: async (args: { path: string }) => { + if (!args.path) { + return { + success: false, + error: 'INVALID_INPUT', + details: 'path is required', + }; + } + const appConfig = tryGetService(container, AppConfig); + if (!appConfig || !appConfig.workspaceDir) { + return { + success: false, + error: 'SERVICE_UNAVAILABLE', + details: 'No workspace directory available', + }; + } + const fileService = tryGetService(container, IFileServiceClient); + if (!fileService) { + return { + success: false, + error: 'SERVICE_UNAVAILABLE', + details: 'IFileServiceClient not registered in DI container', + }; + } + try { + const absolutePath = resolveWorkspacePath(appConfig.workspaceDir, args.path); + const uri = toUri(absolutePath); + const fileStat = await fileService.getFileStat(uri, true); + if (!fileStat) { + return { + success: false, + error: 'FILE_NOT_FOUND', + details: `Directory not found: ${args.path}`, + }; + } + if (!fileStat.isDirectory) { + return { + success: false, + error: 'NOT_A_DIRECTORY', + details: `Path is a file, not a directory: ${args.path}`, + }; + } + const entries = (fileStat.children || []).map((child: any) => ({ + name: child.uri ? child.uri.split('/').pop() : 'unknown', + isDirectory: child.isDirectory, + size: child.size, + })); + return { + success: true, + result: { + path: args.path, + entries, + total: entries.length, + }, + }; + } catch (err) { + return { + success: false, + error: classifyError(err), + details: safeErrorMessage(err), + }; + } + }, + }, + { signal: controller.signal }, + ); + + // ----- file_stat ----- + ctx.registerTool( + { + name: 'file_stat', + description: + 'Get metadata about a file or directory. Returns size, isDirectory, lastModified, and other stat info.', + inputSchema: { + type: 'object', + properties: { + path: { + type: 'string', + description: 'The relative path of the file or directory, from the workspace root.', + }, + }, + required: ['path'], + }, + execute: async (args: { path: string }) => { + if (!args.path) { + return { + success: false, + error: 'INVALID_INPUT', + details: 'path is required', + }; + } + const appConfig = tryGetService(container, AppConfig); + if (!appConfig || !appConfig.workspaceDir) { + return { + success: false, + error: 'SERVICE_UNAVAILABLE', + details: 'No workspace directory available', + }; + } + const fileService = tryGetService(container, IFileServiceClient); + if (!fileService) { + return { + success: false, + error: 'SERVICE_UNAVAILABLE', + details: 'IFileServiceClient not registered in DI container', + }; + } + try { + const absolutePath = resolveWorkspacePath(appConfig.workspaceDir, args.path); + const uri = toUri(absolutePath); + const fileStat = await fileService.getFileStat(uri); + if (!fileStat) { + return { + success: false, + error: 'FILE_NOT_FOUND', + details: `Path not found: ${args.path}`, + }; + } + return { + success: true, + result: { + path: args.path, + isDirectory: fileStat.isDirectory, + size: fileStat.size, + lastModified: fileStat.lastModification, + isReadonly: fileStat.readonly, + }, + }; + } catch (err) { + return { + success: false, + error: classifyError(err), + details: safeErrorMessage(err), + }; + } + }, + }, + { signal: controller.signal }, + ); + + // ----- file_exists ----- + ctx.registerTool( + { + name: 'file_exists', + description: 'Check whether a file or directory exists at the given path. Returns true or false.', + inputSchema: { + type: 'object', + properties: { + path: { + type: 'string', + description: 'The relative path to check, from the workspace root.', + }, + }, + required: ['path'], + }, + execute: async (args: { path: string }) => { + if (!args.path) { + return { + success: false, + error: 'INVALID_INPUT', + details: 'path is required', + }; + } + const appConfig = tryGetService(container, AppConfig); + if (!appConfig || !appConfig.workspaceDir) { + return { + success: false, + error: 'SERVICE_UNAVAILABLE', + details: 'No workspace directory available', + }; + } + const fileService = tryGetService(container, IFileServiceClient); + if (!fileService) { + return { + success: false, + error: 'SERVICE_UNAVAILABLE', + details: 'IFileServiceClient not registered in DI container', + }; + } + try { + const absolutePath = resolveWorkspacePath(appConfig.workspaceDir, args.path); + const uri = toUri(absolutePath); + const exists = await fileService.access(uri); + return { + success: true, + result: { + path: args.path, + exists, + }, + }; + } catch (err) { + return { + success: false, + error: classifyError(err), + details: safeErrorMessage(err), + }; + } + }, + }, + { signal: controller.signal }, + ); + + // ----- file_create ----- + ctx.registerTool( + { + name: 'file_create', + description: + 'Create an empty file or a new directory. Use "type: directory" to create a folder instead of a file.', + inputSchema: { + type: 'object', + properties: { + path: { + type: 'string', + description: 'The relative path to create, from the workspace root.', + }, + type: { + type: 'string', + enum: ['file', 'directory'], + description: 'Whether to create a "file" or "directory". Defaults to "file".', + }, + }, + required: ['path'], + }, + execute: async (args: { path: string; type?: 'file' | 'directory' }) => { + if (!args.path) { + return { + success: false, + error: 'INVALID_INPUT', + details: 'path is required', + }; + } + const appConfig = tryGetService(container, AppConfig); + if (!appConfig || !appConfig.workspaceDir) { + return { + success: false, + error: 'SERVICE_UNAVAILABLE', + details: 'No workspace directory available', + }; + } + const fileService = tryGetService(container, IFileServiceClient); + if (!fileService) { + return { + success: false, + error: 'SERVICE_UNAVAILABLE', + details: 'IFileServiceClient not registered in DI container', + }; + } + try { + const absolutePath = resolveWorkspacePath(appConfig.workspaceDir, args.path); + const uri = toUri(absolutePath); + const existingStat = await fileService.getFileStat(uri); + if (existingStat) { + return { + success: false, + error: 'FILE_EXISTS', + details: `Path already exists: ${args.path}`, + }; + } + let result: any; + if (args.type === 'directory') { + result = await fileService.createFolder(uri); + } else { + result = await fileService.createFile(uri); + } + return { + success: true, + result: { + path: args.path, + type: args.type || 'file', + created: true, + }, + }; + } catch (err) { + return { + success: false, + error: classifyError(err), + details: safeErrorMessage(err), + }; + } + }, + }, + { signal: controller.signal }, + ); + + // ----- file_delete ----- + ctx.registerTool( + { + name: 'file_delete', + description: 'Delete a file or directory. Use recursive: true to delete a directory and its contents.', + inputSchema: { + type: 'object', + properties: { + path: { + type: 'string', + description: 'The relative path to delete, from the workspace root.', + }, + recursive: { + type: 'boolean', + description: 'Whether to delete a directory and all its contents. Required for directories.', + }, + }, + required: ['path'], + }, + execute: async (args: { path: string; recursive?: boolean }) => { + if (!args.path) { + return { + success: false, + error: 'INVALID_INPUT', + details: 'path is required', + }; + } + const appConfig = tryGetService(container, AppConfig); + if (!appConfig || !appConfig.workspaceDir) { + return { + success: false, + error: 'SERVICE_UNAVAILABLE', + details: 'No workspace directory available', + }; + } + const fileService = tryGetService(container, IFileServiceClient); + if (!fileService) { + return { + success: false, + error: 'SERVICE_UNAVAILABLE', + details: 'IFileServiceClient not registered in DI container', + }; + } + try { + const absolutePath = resolveWorkspacePath(appConfig.workspaceDir, args.path); + const uri = toUri(absolutePath); + const existingStat = await fileService.getFileStat(uri); + if (!existingStat) { + return { + success: false, + error: 'FILE_NOT_FOUND', + details: `Path not found: ${args.path}`, + }; + } + if (existingStat.isDirectory && !args.recursive) { + return { + success: false, + error: 'IS_DIRECTORY', + details: 'Path is a directory. Use recursive: true to delete directories.', + }; + } + await fileService.delete(uri); + return { + success: true, + result: { + path: args.path, + deleted: true, + }, + }; + } catch (err) { + return { + success: false, + error: classifyError(err), + details: safeErrorMessage(err), + }; + } + }, + }, + { signal: controller.signal }, + ); + + // ----- file_move ----- + ctx.registerTool( + { + name: 'file_move', + description: 'Move or rename a file or directory from sourcePath to targetPath.', + inputSchema: { + type: 'object', + properties: { + sourcePath: { + type: 'string', + description: 'The relative source path to move, from the workspace root.', + }, + targetPath: { + type: 'string', + description: 'The relative target path to move to, from the workspace root.', + }, + }, + required: ['sourcePath', 'targetPath'], + }, + execute: async (args: { sourcePath: string; targetPath: string }) => { + if (!args.sourcePath || !args.targetPath) { + return { + success: false, + error: 'INVALID_INPUT', + details: 'sourcePath and targetPath are required', + }; + } + const appConfig = tryGetService(container, AppConfig); + if (!appConfig || !appConfig.workspaceDir) { + return { + success: false, + error: 'SERVICE_UNAVAILABLE', + details: 'No workspace directory available', + }; + } + const fileService = tryGetService(container, IFileServiceClient); + if (!fileService) { + return { + success: false, + error: 'SERVICE_UNAVAILABLE', + details: 'IFileServiceClient not registered in DI container', + }; + } + try { + const sourceAbsolute = resolveWorkspacePath(appConfig.workspaceDir, args.sourcePath); + const targetAbsolute = resolveWorkspacePath(appConfig.workspaceDir, args.targetPath); + const sourceUri = toUri(sourceAbsolute); + const targetUri = toUri(targetAbsolute); + const result = await fileService.move(sourceUri, targetUri); + return { + success: true, + result: { + sourcePath: args.sourcePath, + targetPath: args.targetPath, + moved: true, + }, + }; + } catch (err) { + return { + success: false, + error: classifyError(err), + details: safeErrorMessage(err), + }; + } + }, + }, + { signal: controller.signal }, + ); + + // ----- file_copy ----- + ctx.registerTool( + { + name: 'file_copy', + description: 'Copy a file or directory from sourcePath to targetPath.', + inputSchema: { + type: 'object', + properties: { + sourcePath: { + type: 'string', + description: 'The relative source path to copy, from the workspace root.', + }, + targetPath: { + type: 'string', + description: 'The relative target path to copy to, from the workspace root.', + }, + }, + required: ['sourcePath', 'targetPath'], + }, + execute: async (args: { sourcePath: string; targetPath: string }) => { + if (!args.sourcePath || !args.targetPath) { + return { + success: false, + error: 'INVALID_INPUT', + details: 'sourcePath and targetPath are required', + }; + } + const appConfig = tryGetService(container, AppConfig); + if (!appConfig || !appConfig.workspaceDir) { + return { + success: false, + error: 'SERVICE_UNAVAILABLE', + details: 'No workspace directory available', + }; + } + const fileService = tryGetService(container, IFileServiceClient); + if (!fileService) { + return { + success: false, + error: 'SERVICE_UNAVAILABLE', + details: 'IFileServiceClient not registered in DI container', + }; + } + try { + const sourceAbsolute = resolveWorkspacePath(appConfig.workspaceDir, args.sourcePath); + const targetAbsolute = resolveWorkspacePath(appConfig.workspaceDir, args.targetPath); + const sourceUri = toUri(sourceAbsolute); + const targetUri = toUri(targetAbsolute); + await fileService.copy(sourceUri, targetUri); + return { + success: true, + result: { + sourcePath: args.sourcePath, + targetPath: args.targetPath, + copied: true, + }, + }; + } catch (err) { + return { + success: false, + error: classifyError(err), + details: safeErrorMessage(err), + }; + } + }, + }, + { signal: controller.signal }, + ); + + return { dispose: () => controller.abort() }; +} diff --git a/packages/ai-native/src/browser/acp/webmcp-group-registry.ts b/packages/ai-native/src/browser/acp/webmcp-group-registry.ts new file mode 100644 index 0000000000..36222bbc81 --- /dev/null +++ b/packages/ai-native/src/browser/acp/webmcp-group-registry.ts @@ -0,0 +1,83 @@ +import { Injectable } from '@opensumi/di'; + +import type { + WebMcpGroupDef, + WebMcpGroupInfo, + WebMcpToolResult, +} from '@opensumi/ide-core-common/lib/types/ai-native/acp-types'; + +export interface WebMcpToolExecute { + method: string; + description: string; + inputSchema: Record; + execute: (params: Record) => Promise; +} + +export interface WebMcpGroupRegistration { + name: string; + description: string; + defaultLoaded: boolean; + tools: WebMcpToolExecute[]; +} + +@Injectable() +export class WebMcpGroupRegistry { + private groups = new Map(); + + registerGroup(group: WebMcpGroupRegistration): void { + if (this.groups.has(group.name)) { + // eslint-disable-next-line no-console + console.warn(`[WebMCP] Group "${group.name}" already registered, overwriting`); + } + this.groups.set(group.name, group); + } + + getGroupDefinitions(): WebMcpGroupDef[] { + return Array.from(this.groups.values()).map((g) => ({ + name: g.name, + description: g.description, + defaultLoaded: g.defaultLoaded, + tools: g.tools.map((t) => ({ + method: t.method, + description: t.description, + inputSchema: t.inputSchema, + })), + })); + } + + listGroups(loadedGroups: Set): WebMcpGroupInfo[] { + return Array.from(this.groups.values()).map((g) => ({ + name: g.name, + description: g.description, + toolCount: g.tools.length, + loaded: loadedGroups.has(g.name), + })); + } + + executeTool(groupName: string, toolAction: string, params: Record): Promise { + const group = this.groups.get(groupName); + if (!group) { + return Promise.resolve({ + success: false, + error: 'TOOL_NOT_FOUND', + details: `Group "${groupName}" not found`, + }); + } + const method = `_opensumi/${groupName}/${toolAction}`; + const tool = group.tools.find((t) => t.method === method); + if (!tool) { + return Promise.resolve({ + success: false, + error: 'TOOL_NOT_FOUND', + details: `Tool "${method}" not found in group "${groupName}"`, + }); + } + return tool.execute(params); + } + + getDefaultGroupNames(): string[] { + return Array.from(this.groups.values()) + .filter((g) => g.defaultLoaded) + .map((g) => g.name); + } +} diff --git a/packages/ai-native/src/browser/acp/webmcp-groups/editor.webmcp-group.ts b/packages/ai-native/src/browser/acp/webmcp-groups/editor.webmcp-group.ts new file mode 100644 index 0000000000..f823e6c09c --- /dev/null +++ b/packages/ai-native/src/browser/acp/webmcp-groups/editor.webmcp-group.ts @@ -0,0 +1,395 @@ +/** + * WebMCP group definition for editor operations. + * + * Provides tools for AI agents to open, close, navigate, and manipulate + * editor tabs and selections within the IDE. + * + * Tools follow the naming convention: _opensumi/editor/{action} + */ +import { Injector } from '@opensumi/di'; +import { CommandService, URI } from '@opensumi/ide-core-common'; +import { IEditor, IResourceOpenOptions, WorkbenchEditorService } from '@opensumi/ide-editor'; + +import { WebMcpGroupRegistration } from '../webmcp-group-registry'; +import { classifyError, errorResult, serviceUnavailableResult, successResult, tryGetService } from '../webmcp-utils'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +interface ActiveEditorInfo { + path: string | null; + selection: { + startLine: number; + startCol: number; + endLine: number; + endCol: number; + } | null; +} + +function getActiveEditorInfo(editorService: WorkbenchEditorService): ActiveEditorInfo | null { + const editor: IEditor | null = editorService.currentEditor; + if (!editor) { + return null; + } + const uri = editor.currentUri; + const selections = editor.getSelections(); + const primarySelection = selections && selections.length > 0 ? selections[0] : null; + + return { + path: uri ? uri.codeUri.fsPath : null, + selection: primarySelection + ? { + startLine: primarySelection.selectionStartLineNumber, + startCol: primarySelection.selectionStartColumn, + endLine: primarySelection.positionLineNumber, + endCol: primarySelection.positionColumn, + } + : null, + }; +} + +// --------------------------------------------------------------------------- +// Group definition +// --------------------------------------------------------------------------- + +export function createEditorGroup(container: Injector): WebMcpGroupRegistration { + return { + name: 'editor', + description: '编辑器操作(打开、关闭、跳转、格式化等)', + defaultLoaded: true, + tools: [ + // ----- _opensumi/editor/open ----- + { + method: '_opensumi/editor/open', + description: + 'Open a file in the editor. Optionally specify a line and column to scroll to. Returns the editor info for the opened file.', + inputSchema: { + type: 'object', + properties: { + path: { + type: 'string', + description: 'The absolute or workspace-relative path of the file to open.', + }, + line: { + type: 'number', + description: 'The line number to scroll to (1-based).', + }, + column: { + type: 'number', + description: 'The column number to scroll to (1-based).', + }, + }, + required: ['path'], + }, + execute: async (params: Record) => { + const filePath = params.path as string; + if (!filePath) { + return errorResult('INVALID_INPUT', new Error('path is required')); + } + const editorService = tryGetService(container, WorkbenchEditorService); + if (!editorService) { + return serviceUnavailableResult('WorkbenchEditorService'); + } + try { + const uri = URI.file(filePath); + const options: IResourceOpenOptions = {}; + const line = params.line as number | undefined; + const column = params.column as number | undefined; + if (line !== undefined) { + options.range = { + startLineNumber: line, + startColumn: column ?? 1, + endLineNumber: line, + endColumn: column ?? 1, + }; + options.revealRangeInCenter = true; + } + await editorService.open(uri, options); + const info = getActiveEditorInfo(editorService); + return successResult({ path: filePath, editor: info }); + } catch (err) { + return errorResult(classifyError(err), err); + } + }, + }, + + // ----- _opensumi/editor/close ----- + { + method: '_opensumi/editor/close', + description: 'Close the editor tab for the given file path.', + inputSchema: { + type: 'object', + properties: { + path: { + type: 'string', + description: 'The absolute or workspace-relative path of the file to close.', + }, + }, + required: ['path'], + }, + execute: async (params: Record) => { + const filePath = params.path as string; + if (!filePath) { + return errorResult('INVALID_INPUT', new Error('path is required')); + } + const editorService = tryGetService(container, WorkbenchEditorService); + if (!editorService) { + return serviceUnavailableResult('WorkbenchEditorService'); + } + try { + const uri = URI.file(filePath); + await editorService.close(uri); + return successResult({ path: filePath, closed: true }); + } catch (err) { + return errorResult(classifyError(err), err); + } + }, + }, + + // ----- _opensumi/editor/getActive ----- + { + method: '_opensumi/editor/getActive', + description: 'Get information about the currently active editor, including file path and selection range.', + inputSchema: { + type: 'object', + properties: {}, + }, + execute: async () => { + const editorService = tryGetService(container, WorkbenchEditorService); + if (!editorService) { + return serviceUnavailableResult('WorkbenchEditorService'); + } + try { + const info = getActiveEditorInfo(editorService); + if (!info) { + return successResult({ path: null, selection: null, active: false }); + } + return successResult({ ...info, active: true }); + } catch (err) { + return errorResult(classifyError(err), err); + } + }, + }, + + // ----- _opensumi/editor/setSelection ----- + { + method: '_opensumi/editor/setSelection', + description: + 'Set the selection range in the editor. Opens the file first if it is not already open, then sets the selection to the specified line range.', + inputSchema: { + type: 'object', + properties: { + path: { + type: 'string', + description: 'The absolute or workspace-relative path of the file.', + }, + startLine: { + type: 'number', + description: 'The start line of the selection (1-based).', + }, + endLine: { + type: 'number', + description: 'The end line of the selection (1-based). Defaults to startLine if omitted.', + }, + }, + required: ['path', 'startLine'], + }, + execute: async (params: Record) => { + const filePath = params.path as string; + const startLine = params.startLine as number; + const endLine = (params.endLine as number) ?? startLine; + if (!filePath) { + return errorResult('INVALID_INPUT', new Error('path is required')); + } + if (!startLine || startLine < 1) { + return errorResult('INVALID_INPUT', new Error('startLine must be a positive number')); + } + const editorService = tryGetService(container, WorkbenchEditorService); + if (!editorService) { + return serviceUnavailableResult('WorkbenchEditorService'); + } + try { + const uri = URI.file(filePath); + await editorService.open(uri, { + range: { + startLineNumber: startLine, + startColumn: 1, + endLineNumber: endLine, + endColumn: 1, + }, + revealRangeInCenter: true, + }); + return successResult({ path: filePath, startLine, endLine }); + } catch (err) { + return errorResult(classifyError(err), err); + } + }, + }, + + // ----- _opensumi/editor/format ----- + { + method: '_opensumi/editor/format', + description: 'Format the document at the given path using the editor format command.', + inputSchema: { + type: 'object', + properties: { + path: { + type: 'string', + description: 'The absolute or workspace-relative path of the file to format.', + }, + }, + required: ['path'], + }, + execute: async (params: Record) => { + const filePath = params.path as string; + if (!filePath) { + return errorResult('INVALID_INPUT', new Error('path is required')); + } + const editorService = tryGetService(container, WorkbenchEditorService); + if (!editorService) { + return serviceUnavailableResult('WorkbenchEditorService'); + } + const commandService = tryGetService(container, CommandService); + if (!commandService) { + return serviceUnavailableResult('CommandService'); + } + try { + // Open the file first to ensure it is the active editor + const uri = URI.file(filePath); + await editorService.open(uri, { focus: true }); + await commandService.executeCommand('editor.action.formatDocument'); + return successResult({ path: filePath, formatted: true }); + } catch (err) { + return errorResult(classifyError(err), err); + } + }, + }, + + // ----- _opensumi/editor/fold ----- + { + method: '_opensumi/editor/fold', + description: 'Fold code at the specified line in the editor. Opens the file first if needed.', + inputSchema: { + type: 'object', + properties: { + path: { + type: 'string', + description: 'The absolute or workspace-relative path of the file.', + }, + startLine: { + type: 'number', + description: 'The line number at which to fold code (1-based).', + }, + }, + required: ['path', 'startLine'], + }, + execute: async (params: Record) => { + const filePath = params.path as string; + const startLine = params.startLine as number; + if (!filePath) { + return errorResult('INVALID_INPUT', new Error('path is required')); + } + if (!startLine || startLine < 1) { + return errorResult('INVALID_INPUT', new Error('startLine must be a positive number')); + } + const editorService = tryGetService(container, WorkbenchEditorService); + if (!editorService) { + return serviceUnavailableResult('WorkbenchEditorService'); + } + const commandService = tryGetService(container, CommandService); + if (!commandService) { + return serviceUnavailableResult('CommandService'); + } + try { + const uri = URI.file(filePath); + await editorService.open(uri, { focus: true }); + await commandService.executeCommand('editor.fold', startLine); + return successResult({ path: filePath, startLine, folded: true }); + } catch (err) { + return errorResult(classifyError(err), err); + } + }, + }, + + // ----- _opensumi/editor/unfold ----- + { + method: '_opensumi/editor/unfold', + description: 'Unfold code at the specified line in the editor. Opens the file first if needed.', + inputSchema: { + type: 'object', + properties: { + path: { + type: 'string', + description: 'The absolute or workspace-relative path of the file.', + }, + startLine: { + type: 'number', + description: 'The line number at which to unfold code (1-based).', + }, + }, + required: ['path', 'startLine'], + }, + execute: async (params: Record) => { + const filePath = params.path as string; + const startLine = params.startLine as number; + if (!filePath) { + return errorResult('INVALID_INPUT', new Error('path is required')); + } + if (!startLine || startLine < 1) { + return errorResult('INVALID_INPUT', new Error('startLine must be a positive number')); + } + const editorService = tryGetService(container, WorkbenchEditorService); + if (!editorService) { + return serviceUnavailableResult('WorkbenchEditorService'); + } + const commandService = tryGetService(container, CommandService); + if (!commandService) { + return serviceUnavailableResult('CommandService'); + } + try { + const uri = URI.file(filePath); + await editorService.open(uri, { focus: true }); + await commandService.executeCommand('editor.unfold', startLine); + return successResult({ path: filePath, startLine, unfolded: true }); + } catch (err) { + return errorResult(classifyError(err), err); + } + }, + }, + + // ----- _opensumi/editor/save ----- + { + method: '_opensumi/editor/save', + description: 'Save the file at the given path.', + inputSchema: { + type: 'object', + properties: { + path: { + type: 'string', + description: 'The absolute or workspace-relative path of the file to save.', + }, + }, + required: ['path'], + }, + execute: async (params: Record) => { + const filePath = params.path as string; + if (!filePath) { + return errorResult('INVALID_INPUT', new Error('path is required')); + } + const editorService = tryGetService(container, WorkbenchEditorService); + if (!editorService) { + return serviceUnavailableResult('WorkbenchEditorService'); + } + try { + const uri = URI.file(filePath); + await editorService.save(uri); + return successResult({ path: filePath, saved: true }); + } catch (err) { + return errorResult(classifyError(err), err); + } + }, + }, + ], + }; +} diff --git a/packages/ai-native/src/browser/acp/webmcp-groups/file.webmcp-group.ts b/packages/ai-native/src/browser/acp/webmcp-groups/file.webmcp-group.ts new file mode 100644 index 0000000000..f13469e588 --- /dev/null +++ b/packages/ai-native/src/browser/acp/webmcp-groups/file.webmcp-group.ts @@ -0,0 +1,494 @@ +/** + * WebMCP group definition for file management. + * + * Mirrors the file_* tools from webmcp-file-tools.registry.ts but wrapped + * in the WebMcpGroupRegistration interface for the ACP channel. + * + * Tools follow the naming convention: _opensumi/file/{action} + */ +import { Injector } from '@opensumi/di'; +import { AppConfig } from '@opensumi/ide-core-browser'; +import { URI } from '@opensumi/ide-core-common'; +import { IFileServiceClient } from '@opensumi/ide-file-service'; + +import { WebMcpGroupRegistration, WebMcpToolExecute } from '../webmcp-group-registry'; +import { classifyError, errorResult, serviceUnavailableResult, successResult, tryGetService } from '../webmcp-utils'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function resolveWorkspacePath(workspaceDir: string, relativePath: string): string { + if (relativePath.startsWith('/')) { + return relativePath; + } + return `${workspaceDir}/${relativePath}`.replace(/\/+/g, '/'); +} + +function toUri(filePath: string): string { + return URI.file(filePath).toString(); +} + +// --------------------------------------------------------------------------- +// Group definition +// --------------------------------------------------------------------------- + +export function createFileGroup(container: Injector): WebMcpGroupRegistration { + return { + name: 'file', + description: '文件读写和管理操作', + defaultLoaded: true, + tools: [ + // ----- _opensumi/file/getWorkspaceRoot ----- + { + method: '_opensumi/file/getWorkspaceRoot', + description: + 'Get the absolute path of the current workspace root directory. Use this to understand the base path for relative file operations.', + inputSchema: { + type: 'object', + properties: {}, + }, + execute: async () => { + const appConfig = tryGetService(container, AppConfig); + if (!appConfig) { + return serviceUnavailableResult('AppConfig'); + } + try { + return successResult({ workspaceRoot: appConfig.workspaceDir }); + } catch (err) { + return errorResult(classifyError(err), err); + } + }, + }, + + // ----- _opensumi/file/read ----- + { + method: '_opensumi/file/read', + description: + 'Read the contents of a file. Returns the file content as text. Use relative paths from the workspace root.', + inputSchema: { + type: 'object', + properties: { + path: { + type: 'string', + description: 'The relative path of the file to read, from the workspace root.', + }, + }, + required: ['path'], + }, + execute: async (params: Record) => { + const filePath = params.path as string; + if (!filePath) { + return errorResult('INVALID_INPUT', new Error('path is required')); + } + const appConfig = tryGetService(container, AppConfig); + if (!appConfig || !appConfig.workspaceDir) { + return serviceUnavailableResult('AppConfig'); + } + const fileService = tryGetService(container, IFileServiceClient); + if (!fileService) { + return serviceUnavailableResult('IFileServiceClient'); + } + try { + const absolutePath = resolveWorkspacePath(appConfig.workspaceDir, filePath); + const uri = toUri(absolutePath); + const fileStat = await fileService.getFileStat(uri); + if (!fileStat) { + return errorResult('FILE_NOT_FOUND', new Error(`File not found: ${filePath}`)); + } + if (fileStat.isDirectory) { + return errorResult('IS_DIRECTORY', new Error(`Path is a directory, not a file: ${filePath}`)); + } + const result = await fileService.readFile(uri); + const content = result.content.toString(); + return successResult({ path: filePath, content, size: content.length }); + } catch (err) { + return errorResult(classifyError(err), err); + } + }, + }, + + // ----- _opensumi/file/write ----- + { + method: '_opensumi/file/write', + description: + 'Write content to a file. Creates the file if it does not exist, overwrites if it does. Creates parent directories automatically.', + inputSchema: { + type: 'object', + properties: { + path: { + type: 'string', + description: 'The relative path of the file to write, from the workspace root.', + }, + content: { + type: 'string', + description: 'The content to write to the file.', + }, + }, + required: ['path', 'content'], + }, + execute: async (params: Record) => { + const filePath = params.path as string; + const content = params.content as string; + if (!filePath || content === undefined) { + return errorResult('INVALID_INPUT', new Error('path and content are required')); + } + const appConfig = tryGetService(container, AppConfig); + if (!appConfig || !appConfig.workspaceDir) { + return serviceUnavailableResult('AppConfig'); + } + const fileService = tryGetService(container, IFileServiceClient); + if (!fileService) { + return serviceUnavailableResult('IFileServiceClient'); + } + try { + const absolutePath = resolveWorkspacePath(appConfig.workspaceDir, filePath); + const uri = toUri(absolutePath); + const existingStat = await fileService.getFileStat(uri); + if (existingStat) { + await fileService.setContent(existingStat, content); + } else { + await fileService.createFile(uri, { content }); + } + return successResult({ path: filePath, written: true, size: content.length }); + } catch (err) { + return errorResult(classifyError(err), err); + } + }, + }, + + // ----- _opensumi/file/list ----- + { + method: '_opensumi/file/list', + description: + 'List the contents of a directory. Returns an array of file/directory entries with metadata. Use "." for the workspace root.', + inputSchema: { + type: 'object', + properties: { + path: { + type: 'string', + description: + 'The relative path of the directory to list, from the workspace root. Use "." for workspace root.', + }, + }, + required: ['path'], + }, + execute: async (params: Record) => { + const dirPath = params.path as string; + if (!dirPath) { + return errorResult('INVALID_INPUT', new Error('path is required')); + } + const appConfig = tryGetService(container, AppConfig); + if (!appConfig || !appConfig.workspaceDir) { + return serviceUnavailableResult('AppConfig'); + } + const fileService = tryGetService(container, IFileServiceClient); + if (!fileService) { + return serviceUnavailableResult('IFileServiceClient'); + } + try { + const absolutePath = resolveWorkspacePath(appConfig.workspaceDir, dirPath); + const uri = toUri(absolutePath); + const fileStat = await fileService.getFileStat(uri, true); + if (!fileStat) { + return errorResult('FILE_NOT_FOUND', new Error(`Directory not found: ${dirPath}`)); + } + if (!fileStat.isDirectory) { + return errorResult('NOT_A_DIRECTORY', new Error(`Path is a file, not a directory: ${dirPath}`)); + } + const entries = (fileStat.children || []).map((child: any) => ({ + name: child.uri ? child.uri.split('/').pop() : 'unknown', + isDirectory: child.isDirectory, + size: child.size, + })); + return successResult({ path: dirPath, entries, total: entries.length }); + } catch (err) { + return errorResult(classifyError(err), err); + } + }, + }, + + // ----- _opensumi/file/stat ----- + { + method: '_opensumi/file/stat', + description: + 'Get metadata about a file or directory. Returns size, isDirectory, lastModified, and other stat info.', + inputSchema: { + type: 'object', + properties: { + path: { + type: 'string', + description: 'The relative path of the file or directory, from the workspace root.', + }, + }, + required: ['path'], + }, + execute: async (params: Record) => { + const filePath = params.path as string; + if (!filePath) { + return errorResult('INVALID_INPUT', new Error('path is required')); + } + const appConfig = tryGetService(container, AppConfig); + if (!appConfig || !appConfig.workspaceDir) { + return serviceUnavailableResult('AppConfig'); + } + const fileService = tryGetService(container, IFileServiceClient); + if (!fileService) { + return serviceUnavailableResult('IFileServiceClient'); + } + try { + const absolutePath = resolveWorkspacePath(appConfig.workspaceDir, filePath); + const uri = toUri(absolutePath); + const fileStat = await fileService.getFileStat(uri); + if (!fileStat) { + return errorResult('FILE_NOT_FOUND', new Error(`Path not found: ${filePath}`)); + } + return successResult({ + path: filePath, + isDirectory: fileStat.isDirectory, + size: fileStat.size, + lastModified: fileStat.lastModification, + isReadonly: fileStat.readonly, + }); + } catch (err) { + return errorResult(classifyError(err), err); + } + }, + }, + + // ----- _opensumi/file/exists ----- + { + method: '_opensumi/file/exists', + description: 'Check whether a file or directory exists at the given path. Returns true or false.', + inputSchema: { + type: 'object', + properties: { + path: { + type: 'string', + description: 'The relative path to check, from the workspace root.', + }, + }, + required: ['path'], + }, + execute: async (params: Record) => { + const filePath = params.path as string; + if (!filePath) { + return errorResult('INVALID_INPUT', new Error('path is required')); + } + const appConfig = tryGetService(container, AppConfig); + if (!appConfig || !appConfig.workspaceDir) { + return serviceUnavailableResult('AppConfig'); + } + const fileService = tryGetService(container, IFileServiceClient); + if (!fileService) { + return serviceUnavailableResult('IFileServiceClient'); + } + try { + const absolutePath = resolveWorkspacePath(appConfig.workspaceDir, filePath); + const uri = toUri(absolutePath); + const exists = await fileService.access(uri); + return successResult({ path: filePath, exists }); + } catch (err) { + return errorResult(classifyError(err), err); + } + }, + }, + + // ----- _opensumi/file/create ----- + { + method: '_opensumi/file/create', + description: + 'Create an empty file or a new directory. Use "type: directory" to create a folder instead of a file.', + inputSchema: { + type: 'object', + properties: { + path: { + type: 'string', + description: 'The relative path to create, from the workspace root.', + }, + type: { + type: 'string', + enum: ['file', 'directory'], + description: 'Whether to create a "file" or "directory". Defaults to "file".', + }, + }, + required: ['path'], + }, + execute: async (params: Record) => { + const filePath = params.path as string; + const createType = (params.type as 'file' | 'directory') || 'file'; + if (!filePath) { + return errorResult('INVALID_INPUT', new Error('path is required')); + } + const appConfig = tryGetService(container, AppConfig); + if (!appConfig || !appConfig.workspaceDir) { + return serviceUnavailableResult('AppConfig'); + } + const fileService = tryGetService(container, IFileServiceClient); + if (!fileService) { + return serviceUnavailableResult('IFileServiceClient'); + } + try { + const absolutePath = resolveWorkspacePath(appConfig.workspaceDir, filePath); + const uri = toUri(absolutePath); + const existingStat = await fileService.getFileStat(uri); + if (existingStat) { + return errorResult('FILE_EXISTS', new Error(`Path already exists: ${filePath}`)); + } + if (createType === 'directory') { + await fileService.createFolder(uri); + } else { + await fileService.createFile(uri); + } + return successResult({ path: filePath, type: createType, created: true }); + } catch (err) { + return errorResult(classifyError(err), err); + } + }, + }, + + // ----- _opensumi/file/delete ----- + { + method: '_opensumi/file/delete', + description: 'Delete a file or directory. Use recursive: true to delete a directory and its contents.', + inputSchema: { + type: 'object', + properties: { + path: { + type: 'string', + description: 'The relative path to delete, from the workspace root.', + }, + recursive: { + type: 'boolean', + description: 'Whether to delete a directory and all its contents. Required for directories.', + }, + }, + required: ['path'], + }, + execute: async (params: Record) => { + const filePath = params.path as string; + const recursive = (params.recursive as boolean) ?? false; + if (!filePath) { + return errorResult('INVALID_INPUT', new Error('path is required')); + } + const appConfig = tryGetService(container, AppConfig); + if (!appConfig || !appConfig.workspaceDir) { + return serviceUnavailableResult('AppConfig'); + } + const fileService = tryGetService(container, IFileServiceClient); + if (!fileService) { + return serviceUnavailableResult('IFileServiceClient'); + } + try { + const absolutePath = resolveWorkspacePath(appConfig.workspaceDir, filePath); + const uri = toUri(absolutePath); + const existingStat = await fileService.getFileStat(uri); + if (!existingStat) { + return errorResult('FILE_NOT_FOUND', new Error(`Path not found: ${filePath}`)); + } + if (existingStat.isDirectory && !recursive) { + return errorResult( + 'IS_DIRECTORY', + new Error('Path is a directory. Use recursive: true to delete directories.'), + ); + } + await fileService.delete(uri); + return successResult({ path: filePath, deleted: true }); + } catch (err) { + return errorResult(classifyError(err), err); + } + }, + }, + + // ----- _opensumi/file/move ----- + { + method: '_opensumi/file/move', + description: 'Move or rename a file or directory from source to destination.', + inputSchema: { + type: 'object', + properties: { + source: { + type: 'string', + description: 'The relative source path to move, from the workspace root.', + }, + destination: { + type: 'string', + description: 'The relative destination path to move to, from the workspace root.', + }, + }, + required: ['source', 'destination'], + }, + execute: async (params: Record) => { + const source = params.source as string; + const destination = params.destination as string; + if (!source || !destination) { + return errorResult('INVALID_INPUT', new Error('source and destination are required')); + } + const appConfig = tryGetService(container, AppConfig); + if (!appConfig || !appConfig.workspaceDir) { + return serviceUnavailableResult('AppConfig'); + } + const fileService = tryGetService(container, IFileServiceClient); + if (!fileService) { + return serviceUnavailableResult('IFileServiceClient'); + } + try { + const sourceAbsolute = resolveWorkspacePath(appConfig.workspaceDir, source); + const destinationAbsolute = resolveWorkspacePath(appConfig.workspaceDir, destination); + const sourceUri = toUri(sourceAbsolute); + const destinationUri = toUri(destinationAbsolute); + await fileService.move(sourceUri, destinationUri); + return successResult({ source, destination, moved: true }); + } catch (err) { + return errorResult(classifyError(err), err); + } + }, + }, + + // ----- _opensumi/file/copy ----- + { + method: '_opensumi/file/copy', + description: 'Copy a file or directory from source to destination.', + inputSchema: { + type: 'object', + properties: { + source: { + type: 'string', + description: 'The relative source path to copy, from the workspace root.', + }, + destination: { + type: 'string', + description: 'The relative destination path to copy to, from the workspace root.', + }, + }, + required: ['source', 'destination'], + }, + execute: async (params: Record) => { + const source = params.source as string; + const destination = params.destination as string; + if (!source || !destination) { + return errorResult('INVALID_INPUT', new Error('source and destination are required')); + } + const appConfig = tryGetService(container, AppConfig); + if (!appConfig || !appConfig.workspaceDir) { + return serviceUnavailableResult('AppConfig'); + } + const fileService = tryGetService(container, IFileServiceClient); + if (!fileService) { + return serviceUnavailableResult('IFileServiceClient'); + } + try { + const sourceAbsolute = resolveWorkspacePath(appConfig.workspaceDir, source); + const destinationAbsolute = resolveWorkspacePath(appConfig.workspaceDir, destination); + const sourceUri = toUri(sourceAbsolute); + const destinationUri = toUri(destinationAbsolute); + await fileService.copy(sourceUri, destinationUri); + return successResult({ source, destination, copied: true }); + } catch (err) { + return errorResult(classifyError(err), err); + } + }, + }, + ], + }; +} diff --git a/packages/ai-native/src/browser/acp/webmcp-groups/terminal.webmcp-group.ts b/packages/ai-native/src/browser/acp/webmcp-groups/terminal.webmcp-group.ts new file mode 100644 index 0000000000..f1f534047e --- /dev/null +++ b/packages/ai-native/src/browser/acp/webmcp-groups/terminal.webmcp-group.ts @@ -0,0 +1,362 @@ +/** + * Terminal WebMCP group definition for the ACP channel. + * + * Mirrors the terminal_* WebMCP tools from packages/terminal-next/src/browser/webmcp-tools.registry.ts + * but wrapped in the WebMcpGroupRegistration interface used by the group-based ACP tool system. + * + * Tools follow the naming convention: _opensumi/terminal/{action} + */ +import { Injector } from '@opensumi/di'; +import { ITerminalService } from '@opensumi/ide-terminal-next/lib/common'; +import { ITerminalApiService } from '@opensumi/ide-terminal-next/lib/common/api'; +import { ITerminalController } from '@opensumi/ide-terminal-next/lib/common/controller'; + +import { WebMcpGroupRegistration } from '../webmcp-group-registry'; +import { errorResult, serviceUnavailableResult, successResult, tryGetService } from '../webmcp-utils'; + +export function createTerminalGroup(container: Injector): WebMcpGroupRegistration { + return { + name: 'terminal', + description: '终端操作', + defaultLoaded: true, + tools: [ + // ----- _opensumi/terminal/list ----- + { + method: '_opensumi/terminal/list', + description: + 'List all open terminal sessions. Returns an array of terminal info objects including id, name, and isActive. Use this to discover existing terminals before sending commands.', + inputSchema: { + type: 'object', + properties: {}, + }, + execute: async () => { + const terminalApi = tryGetService(container, ITerminalApiService); + if (!terminalApi) { + return serviceUnavailableResult('ITerminalApiService'); + } + try { + const terminals = terminalApi.terminals; + return successResult( + terminals.map((t) => ({ + id: t.id, + name: t.name, + isActive: t.isActive, + })), + ); + } catch (err) { + return errorResult('EXECUTION_ERROR', err); + } + }, + }, + + // ----- _opensumi/terminal/create ----- + { + method: '_opensumi/terminal/create', + description: + 'Create a new terminal session. Optionally specify a shell path or working directory. Returns the terminal id. Use this to open a new terminal for running commands.', + inputSchema: { + type: 'object', + properties: { + name: { + type: 'string', + description: 'Display name for the terminal.', + }, + cwd: { + type: 'string', + description: 'Working directory for the new terminal. Defaults to workspace root.', + }, + shellPath: { + type: 'string', + description: 'Shell executable path (e.g. "/bin/bash", "/bin/zsh"). Defaults to system default.', + }, + }, + }, + execute: async (params: Record) => { + const terminalController = tryGetService(container, ITerminalController); + if (!terminalController) { + return serviceUnavailableResult('ITerminalController'); + } + try { + await terminalController.viewReady.promise; + const client = await terminalController.createTerminal({ + config: params.shellPath ? { executable: params.shellPath as string } : undefined, + cwd: params.cwd as string | undefined, + }); + return successResult({ + id: client.id, + name: client.name, + }); + } catch (err) { + return errorResult('EXECUTION_ERROR', err); + } + }, + }, + + // ----- _opensumi/terminal/executeCommand ----- + { + method: '_opensumi/terminal/executeCommand', + description: + 'Send a text command to a specific terminal session identified by id. The text is typed into the terminal as-is. To execute the command, include a trailing newline (\\n). Get valid ids from _opensumi/terminal/list.', + inputSchema: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'The terminal session ID. Get valid IDs from _opensumi/terminal/list.', + }, + command: { + type: 'string', + description: 'The text to send to the terminal. Append "\\n" to execute the command.', + }, + }, + required: ['id', 'command'], + }, + execute: async (params: Record) => { + const id = params.id as string; + const command = params.command as string; + if (!id || !command) { + return errorResult('EXECUTION_ERROR', new Error('id and command are required')); + } + const terminalApi = tryGetService(container, ITerminalApiService); + if (!terminalApi) { + return serviceUnavailableResult('ITerminalApiService'); + } + try { + terminalApi.sendText(id, command); + return successResult({ + terminalId: id, + commandSent: command, + }); + } catch (err) { + return errorResult('EXECUTION_ERROR', err); + } + }, + }, + + // ----- _opensumi/terminal/show ----- + { + method: '_opensumi/terminal/show', + description: + 'Show/focus a specific terminal session in the terminal panel. Use this to bring a terminal into view.', + inputSchema: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'The terminal session ID to show. Get valid IDs from _opensumi/terminal/list.', + }, + }, + required: ['id'], + }, + execute: async (params: Record) => { + const id = params.id as string; + if (!id) { + return errorResult('EXECUTION_ERROR', new Error('id is required')); + } + const terminalApi = tryGetService(container, ITerminalApiService); + if (!terminalApi) { + return serviceUnavailableResult('ITerminalApiService'); + } + try { + terminalApi.showTerm(id); + return successResult({ terminalId: id }); + } catch (err) { + return errorResult('EXECUTION_ERROR', err); + } + }, + }, + + // ----- _opensumi/terminal/getProcessId ----- + { + method: '_opensumi/terminal/getProcessId', + description: + 'Get the OS process ID (PID) of the shell process running in a terminal session. Returns null if the process has exited.', + inputSchema: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'The terminal session ID. Get valid IDs from _opensumi/terminal/list.', + }, + }, + required: ['id'], + }, + execute: async (params: Record) => { + const id = params.id as string; + if (!id) { + return errorResult('EXECUTION_ERROR', new Error('id is required')); + } + const terminalApi = tryGetService(container, ITerminalApiService); + if (!terminalApi) { + return serviceUnavailableResult('ITerminalApiService'); + } + try { + const pid = await terminalApi.getProcessId(id); + return successResult({ + terminalId: id, + pid: pid ?? null, + }); + } catch (err) { + return errorResult('EXECUTION_ERROR', err); + } + }, + }, + + // ----- _opensumi/terminal/dispose ----- + { + method: '_opensumi/terminal/dispose', + description: + 'Close/kill a terminal session and its underlying shell process. Use this to clean up terminals that are no longer needed.', + inputSchema: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'The terminal session ID to close. Get valid IDs from _opensumi/terminal/list.', + }, + }, + required: ['id'], + }, + execute: async (params: Record) => { + const id = params.id as string; + if (!id) { + return errorResult('EXECUTION_ERROR', new Error('id is required')); + } + const terminalApi = tryGetService(container, ITerminalApiService); + if (!terminalApi) { + return serviceUnavailableResult('ITerminalApiService'); + } + try { + terminalApi.removeTerm(id); + return successResult({ terminalId: id, status: 'disposed' }); + } catch (err) { + return errorResult('EXECUTION_ERROR', err); + } + }, + }, + + // ----- _opensumi/terminal/resize ----- + { + method: '_opensumi/terminal/resize', + description: 'Resize a terminal session to the specified number of columns (width) and rows (height).', + inputSchema: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'The terminal session ID. Get valid IDs from _opensumi/terminal/list.', + }, + cols: { + type: 'number', + description: 'Number of columns (character width) for the terminal.', + }, + rows: { + type: 'number', + description: 'Number of rows (character height) for the terminal.', + }, + }, + required: ['id', 'cols', 'rows'], + }, + execute: async (params: Record) => { + const id = params.id as string; + const cols = params.cols as number; + const rows = params.rows as number; + if (!id || !cols || !rows) { + return errorResult('EXECUTION_ERROR', new Error('id, cols, and rows are required')); + } + const terminalService = tryGetService(container, ITerminalService); + if (!terminalService) { + return serviceUnavailableResult('ITerminalService'); + } + try { + await terminalService.resize(id, cols, rows); + return successResult({ terminalId: id, cols, rows }); + } catch (err) { + return errorResult('EXECUTION_ERROR', err); + } + }, + }, + + // ----- _opensumi/terminal/getOS ----- + { + method: '_opensumi/terminal/getOS', + description: + 'Get the operating system type of the terminal backend (e.g. "Linux", "macOS", "Windows"). Useful for writing platform-specific commands.', + inputSchema: { + type: 'object', + properties: {}, + }, + execute: async () => { + const terminalService = tryGetService(container, ITerminalService); + if (!terminalService) { + return serviceUnavailableResult('ITerminalService'); + } + try { + const os = await terminalService.getOS(); + return successResult({ os }); + } catch (err) { + return errorResult('EXECUTION_ERROR', err); + } + }, + }, + + // ----- _opensumi/terminal/getProfiles ----- + { + method: '_opensumi/terminal/getProfiles', + description: + 'Get the list of available terminal shell profiles (e.g. bash, zsh, PowerShell). Use the profile name with _opensumi/terminal/create to open a specific shell.', + inputSchema: { + type: 'object', + properties: { + autoDetect: { + type: 'boolean', + description: 'Whether to auto-detect available shells. Defaults to true.', + }, + }, + }, + execute: async (params: Record) => { + const terminalService = tryGetService(container, ITerminalService); + if (!terminalService) { + return serviceUnavailableResult('ITerminalService'); + } + try { + const autoDetect = (params.autoDetect ?? true) as boolean; + const profiles = await terminalService.getProfiles(autoDetect); + return successResult( + profiles.map((p: any) => ({ + profileName: p.profileName, + path: p.path, + isAutoDetected: p.isAutoDetected, + })), + ); + } catch (err) { + return errorResult('EXECUTION_ERROR', err); + } + }, + }, + + // ----- _opensumi/terminal/showPanel ----- + { + method: '_opensumi/terminal/showPanel', + description: + 'Show/open the terminal panel in the IDE. Use this to ensure the terminal panel is visible before interacting with terminals.', + inputSchema: { + type: 'object', + properties: {}, + }, + execute: async () => { + const terminalController = tryGetService(container, ITerminalController); + if (!terminalController) { + return serviceUnavailableResult('ITerminalController'); + } + try { + terminalController.showTerminalPanel(); + return successResult({ status: 'shown' }); + } catch (err) { + return errorResult('EXECUTION_ERROR', err); + } + }, + }, + ], + }; +} diff --git a/packages/ai-native/src/browser/acp/webmcp-tools.registry.ts b/packages/ai-native/src/browser/acp/webmcp-tools.registry.ts new file mode 100644 index 0000000000..e7c8b0db5f --- /dev/null +++ b/packages/ai-native/src/browser/acp/webmcp-tools.registry.ts @@ -0,0 +1,556 @@ +/** + * WebMCP tool registry for the ACP (Agent Control Protocol) module. + * + * Registers browser-side tools on `navigator.modelContext` that allow an external + * AI agent to interact with the ACP chat system — listing sessions, sending messages, + * switching sessions, managing session state, and handling permission dialogs. + * + * Tools follow the naming convention: acp_ + * + * PHASE 2: All tools are hand-crafted with proper descriptions, typed input schemas, + * and direct service method calls. Generic registration helpers are kept for Phase 3 + * modules that have not yet been refined. + */ +import { IDisposable, Injector } from '@opensumi/di'; +import { ensureModelContext } from '@opensumi/ide-core-browser/lib/webmcp-polyfill'; +import { ChatServiceToken } from '@opensumi/ide-core-common'; + +import { IChatInternalService, IChatManagerService, IChatMessageStructure } from '../../common'; +import { AcpPermissionBridgeService } from '../acp/permission-bridge.service'; +import { ChatService } from '../chat/chat.api.service'; +import { AcpChatInternalService } from '../chat/chat.internal.service.acp'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function tryGetService(container: Injector, token: unknown): unknown { + try { + return container.get(token as symbol); + } catch { + return null; + } +} + +function classifyError(err: unknown): string { + if (typeof err === 'object' && err !== null) { + const name = (err as Error).name || ''; + if (name.includes('Timeout') || name.includes('timeout')) { + return 'RPC_TIMEOUT'; + } + if (name.includes('Injector') || name.includes('DI')) { + return 'DI_ERROR'; + } + if (name.includes('Permission') || name.includes('denied')) { + return 'PERMISSION_DENIED'; + } + if (name.includes('Abort')) { + return 'ABORTED'; + } + } + return 'EXECUTION_ERROR'; +} + +function safeErrorMessage(err: unknown): string { + const msg = err instanceof Error ? err.message : String(err); + return msg + .replace(/[A-Za-z_]*token[A-Za-z_]*[:=\s]+["']?[A-Za-z0-9+/=]+["']?/gi, '[REDACTED]') + .replace(/[A-Za-z_]*key[A-Za-z_]*[:=\s]+["']?[A-Za-z0-9+/=]+["']?/gi, '[REDACTED]') + .substring(0, 200); +} + +export function createGenericToolExecutor( + container: Injector, + serviceToken: unknown, + methodName: string, +): (args?: Record) => Promise { + return async (args?: Record) => { + const service = tryGetService(container, serviceToken); + if (!service) { + return { + success: false, + error: 'SERVICE_UNAVAILABLE', + details: 'Service not found in DI container', + }; + } + try { + const method = (service as Record)[methodName]; + if (typeof method !== 'function') { + return { + success: false, + error: 'METHOD_NOT_FOUND', + details: `Method ${methodName} not found on service`, + }; + } + const result = args ? await (method as Function)(...Object.values(args)) : await (method as Function)(); + return { success: true, result }; + } catch (err) { + return { + success: false, + error: classifyError(err), + details: safeErrorMessage(err), + }; + } + }; +} + +// --------------------------------------------------------------------------- +// PHASE 2: Hand-crafted tools with proper descriptions and typed input schemas +// --------------------------------------------------------------------------- + +export function registerAcpWebMCPTools(container: Injector): IDisposable { + ensureModelContext(); + + const ctx = navigator.modelContext!; + const controller = new AbortController(); + + ctx.registerTool( + { + name: 'acp_listSessions', + description: + 'List all ACP chat sessions. Returns an array of session objects with sessionId, title, modelId, and threadStatus. Use this to discover existing sessions before switching or sending messages.', + inputSchema: { type: 'object', properties: {} }, + execute: async () => { + const chatInternalService = tryGetService(container, IChatInternalService); + if (!chatInternalService) { + return { + success: false, + error: 'SERVICE_UNAVAILABLE', + details: 'IChatInternalService not registered in DI container', + }; + } + try { + const sessions = (chatInternalService as AcpChatInternalService).getSessions(); + const result = sessions.map((s: any) => ({ + sessionId: s.sessionId, + title: s.title || '', + modelId: s.modelId, + threadStatus: s.threadStatus, + requestCount: s.requests?.length ?? 0, + })); + return { success: true, result }; + } catch (err) { + return { success: false, error: classifyError(err), details: safeErrorMessage(err) }; + } + }, + }, + { signal: controller.signal }, + ); + + ctx.registerTool( + { + name: 'acp_createSession', + description: + 'Create a new ACP chat session and make it the active session. Returns the new sessionId. Use this when you want to start a fresh conversation.', + inputSchema: { type: 'object', properties: {} }, + execute: async () => { + const chatInternalService = tryGetService(container, IChatInternalService); + if (!chatInternalService) { + return { + success: false, + error: 'SERVICE_UNAVAILABLE', + details: 'IChatInternalService not registered in DI container', + }; + } + try { + await (chatInternalService as AcpChatInternalService).createSessionModel(); + const sessionModel = (chatInternalService as AcpChatInternalService).sessionModel; + return { success: true, result: { sessionId: sessionModel?.sessionId, title: sessionModel?.title } }; + } catch (err) { + return { success: false, error: classifyError(err), details: safeErrorMessage(err) }; + } + }, + }, + { signal: controller.signal }, + ); + + ctx.registerTool( + { + name: 'acp_switchSession', + description: + 'Switch the active ACP chat session to the one specified by sessionId. Use this to load a previous conversation or switch between sessions.', + inputSchema: { + type: 'object', + properties: { + sessionId: { + type: 'string', + description: 'The sessionId to switch to. Get valid IDs from acp_listSessions.', + }, + }, + required: ['sessionId'], + }, + execute: async (args: { sessionId: string }) => { + if (!args.sessionId) { + return { success: false, error: 'INVALID_INPUT', details: 'sessionId is required' }; + } + const chatInternalService = tryGetService(container, IChatInternalService); + if (!chatInternalService) { + return { + success: false, + error: 'SERVICE_UNAVAILABLE', + details: 'IChatInternalService not registered in DI container', + }; + } + try { + await (chatInternalService as AcpChatInternalService).activateSession(args.sessionId); + const sessionModel = (chatInternalService as AcpChatInternalService).sessionModel; + return { success: true, result: { sessionId: sessionModel?.sessionId, title: sessionModel?.title } }; + } catch (err) { + return { success: false, error: classifyError(err), details: safeErrorMessage(err) }; + } + }, + }, + { signal: controller.signal }, + ); + + ctx.registerTool( + { + name: 'acp_getSessionState', + description: + 'Get the current active ACP session state, including sessionId, title, modelId, threadStatus (idle/working/errored), message count, and recent request history. Use this to check the agent status after sending a message.', + inputSchema: { type: 'object', properties: {} }, + execute: async () => { + const chatInternalService = tryGetService(container, IChatInternalService); + if (!chatInternalService) { + return { + success: false, + error: 'SERVICE_UNAVAILABLE', + details: 'IChatInternalService not registered in DI container', + }; + } + try { + const sessionModel = (chatInternalService as AcpChatInternalService).sessionModel; + if (!sessionModel) { + return { + success: false, + error: 'NO_ACTIVE_SESSION', + details: 'No active session. Use acp_createSession first.', + }; + } + const requests = sessionModel.requests || []; + return { + success: true, + result: { + sessionId: sessionModel.sessionId, + title: sessionModel.title, + modelId: sessionModel.modelId, + threadStatus: sessionModel.threadStatus, + requestCount: requests.length, + lastRequest: requests.length > 0 ? requests[requests.length - 1]?.message?.prompt : null, + }, + }; + } catch (err) { + return { success: false, error: classifyError(err), details: safeErrorMessage(err) }; + } + }, + }, + { signal: controller.signal }, + ); + + ctx.registerTool( + { + name: 'acp_sendMessage', + description: + 'Send a text message to the active ACP chat session. The message is queued and the agent will process it asynchronously. Use acp_getSessionState to check the response progress. Optionally include image URLs as base64 data URIs.', + inputSchema: { + type: 'object', + properties: { + message: { type: 'string', description: 'The message text to send to the agent.' }, + images: { + type: 'array', + items: { type: 'string' }, + description: 'Optional array of image data URIs (base64) to include with the message.', + } as any, + command: { + type: 'string', + description: + 'Optional slash command to use (e.g. "/explain", "/fix"). Get available commands via acp_getAvailableCommands.', + }, + }, + required: ['message'], + }, + execute: async (args: { message: string; images?: string[]; command?: string }) => { + if (!args.message || args.message.trim().length === 0) { + return { success: false, error: 'INVALID_INPUT', details: 'message is required and cannot be empty' }; + } + const chatService = tryGetService(container, ChatServiceToken) as ChatService; + if (!chatService) { + return { + success: false, + error: 'SERVICE_UNAVAILABLE', + details: 'ChatService not registered in DI container', + }; + } + const chatInternalService = tryGetService(container, IChatInternalService); + if (!chatInternalService) { + return { + success: false, + error: 'SERVICE_UNAVAILABLE', + details: 'IChatInternalService not registered in DI container', + }; + } + try { + const sessionModel = (chatInternalService as AcpChatInternalService).sessionModel; + if (!sessionModel) { + return { + success: false, + error: 'NO_ACTIVE_SESSION', + details: 'No active session. Use acp_createSession first.', + }; + } + const messageData: IChatMessageStructure = { + message: args.message, + images: args.images, + command: args.command, + immediate: true, + }; + chatService.sendMessage(messageData); + return { + success: true, + result: { sessionId: sessionModel.sessionId, status: 'message_sent', message: args.message }, + }; + } catch (err) { + return { success: false, error: classifyError(err), details: safeErrorMessage(err) }; + } + }, + }, + { signal: controller.signal }, + ); + + ctx.registerTool( + { + name: 'acp_clearSession', + description: + 'Clear the active ACP chat session history and create a new blank session. Use this to reset the conversation context. Optionally specify a sessionId to clear a specific session; otherwise clears the current one.', + inputSchema: { + type: 'object', + properties: { + sessionId: { + type: 'string', + description: 'Optional sessionId to clear. If omitted, clears the current active session.', + }, + }, + }, + execute: async (args?: { sessionId?: string }) => { + const chatInternalService = tryGetService(container, IChatInternalService); + if (!chatInternalService) { + return { + success: false, + error: 'SERVICE_UNAVAILABLE', + details: 'IChatInternalService not registered in DI container', + }; + } + try { + await (chatInternalService as AcpChatInternalService).clearSessionModel(args?.sessionId); + const sessionModel = (chatInternalService as AcpChatInternalService).sessionModel; + return { success: true, result: { sessionId: sessionModel?.sessionId } }; + } catch (err) { + return { success: false, error: classifyError(err), details: safeErrorMessage(err) }; + } + }, + }, + { signal: controller.signal }, + ); + + ctx.registerTool( + { + name: 'acp_cancelRequest', + description: + 'Cancel the current in-progress agent request in the active session. Use this to stop a running agent task.', + inputSchema: { type: 'object', properties: {} }, + execute: async () => { + const chatInternalService = tryGetService(container, IChatInternalService); + if (!chatInternalService) { + return { + success: false, + error: 'SERVICE_UNAVAILABLE', + details: 'IChatInternalService not registered in DI container', + }; + } + try { + const sessionModel = (chatInternalService as AcpChatInternalService).sessionModel; + if (!sessionModel) { + return { success: false, error: 'NO_ACTIVE_SESSION', details: 'No active session' }; + } + const chatManagerService = tryGetService(container, IChatManagerService) as unknown as { + cancelRequest(sessionId: string): void; + }; + if (!chatManagerService) { + return { + success: false, + error: 'SERVICE_UNAVAILABLE', + details: 'IChatManagerService not registered in DI container', + }; + } + chatManagerService.cancelRequest(sessionModel.sessionId); + return { success: true, result: { status: 'cancelled' } }; + } catch (err) { + return { success: false, error: classifyError(err), details: safeErrorMessage(err) }; + } + }, + }, + { signal: controller.signal }, + ); + + ctx.registerTool( + { + name: 'acp_getAvailableCommands', + description: + 'Get the list of available slash commands for the current ACP session. Each command has a name and description. Use the command name with acp_sendMessage to invoke a specific command.', + inputSchema: { type: 'object', properties: {} }, + execute: async () => { + const chatInternalService = tryGetService(container, IChatInternalService); + if (!chatInternalService) { + return { + success: false, + error: 'SERVICE_UNAVAILABLE', + details: 'IChatInternalService not registered in DI container', + }; + } + try { + const commands = (chatInternalService as AcpChatInternalService).getAvailableCommands(); + return { success: true, result: commands.map((c: any) => ({ name: c.name, description: c.description })) }; + } catch (err) { + return { success: false, error: classifyError(err), details: safeErrorMessage(err) }; + } + }, + }, + { signal: controller.signal }, + ); + + ctx.registerTool( + { + name: 'acp_setSessionMode', + description: + 'Switch the mode of the active ACP session (e.g. "agent", "chat"). Different modes change how the agent behaves and what tools it has access to.', + inputSchema: { + type: 'object', + properties: { modeId: { type: 'string', description: 'The mode ID to switch to (e.g. "agent", "chat").' } }, + required: ['modeId'], + }, + execute: async (args: { modeId: string }) => { + if (!args.modeId) { + return { success: false, error: 'INVALID_INPUT', details: 'modeId is required' }; + } + const chatInternalService = tryGetService(container, IChatInternalService); + if (!chatInternalService) { + return { + success: false, + error: 'SERVICE_UNAVAILABLE', + details: 'IChatInternalService not registered in DI container', + }; + } + try { + await (chatInternalService as AcpChatInternalService).setSessionMode(args.modeId); + return { success: true, result: { modeId: args.modeId } }; + } catch (err) { + return { success: false, error: classifyError(err), details: safeErrorMessage(err) }; + } + }, + }, + { signal: controller.signal }, + ); + + ctx.registerTool( + { + name: 'acp_showChatView', + description: + 'Show/open the ACP chat view panel in the IDE. Use this to ensure the chat panel is visible to the user.', + inputSchema: { type: 'object', properties: {} }, + execute: async () => { + const chatService = tryGetService(container, ChatServiceToken) as ChatService; + if (!chatService) { + return { + success: false, + error: 'SERVICE_UNAVAILABLE', + details: 'ChatService not registered in DI container', + }; + } + try { + chatService.showChatView(); + return { success: true, result: { status: 'shown' } }; + } catch (err) { + return { success: false, error: classifyError(err), details: safeErrorMessage(err) }; + } + }, + }, + { signal: controller.signal }, + ); + + ctx.registerTool( + { + name: 'acp_getPermissionDialogState', + description: + 'Get the current state of ACP permission dialogs — including the number of active (pending) permission dialogs and the active session ID. Use this to check if the agent is waiting for user permission.', + inputSchema: { type: 'object', properties: {} }, + execute: async () => { + const permissionBridge = tryGetService(container, AcpPermissionBridgeService) as AcpPermissionBridgeService; + if (!permissionBridge) { + return { + success: false, + error: 'SERVICE_UNAVAILABLE', + details: 'AcpPermissionBridgeService not registered in DI container', + }; + } + try { + return { + success: true, + result: { + activeDialogCount: permissionBridge.getActiveDialogCount(), + activeSessionId: permissionBridge.getActiveSession(), + }, + }; + } catch (err) { + return { success: false, error: classifyError(err), details: safeErrorMessage(err) }; + } + }, + }, + { signal: controller.signal }, + ); + + ctx.registerTool( + { + name: 'acp_handlePermissionDialog', + description: + 'Approve or reject a pending ACP permission dialog. Use this after acp_getPermissionDialogState detects a pending dialog. The optionId must match one of the available options (e.g. "allow_once", "allow_always", "reject"). In test mode, use this to auto-approve permission requests.', + inputSchema: { + type: 'object', + properties: { + requestId: { type: 'string', description: 'The requestId of the pending permission dialog.' }, + optionId: { type: 'string', description: 'The option to select: "allow_once", "allow_always", or "reject".' }, + }, + required: ['requestId', 'optionId'], + }, + execute: async (args: { requestId: string; optionId: string }) => { + if (!args.requestId) { + return { success: false, error: 'INVALID_INPUT', details: 'requestId is required' }; + } + if (!args.optionId) { + return { success: false, error: 'INVALID_INPUT', details: 'optionId is required' }; + } + const permissionBridge = tryGetService(container, AcpPermissionBridgeService) as AcpPermissionBridgeService; + if (!permissionBridge) { + return { + success: false, + error: 'SERVICE_UNAVAILABLE', + details: 'AcpPermissionBridgeService not registered in DI container', + }; + } + try { + const kind: string = args.optionId.includes('allow') + ? args.optionId.includes('always') + ? 'allow_always' + : 'allow_once' + : 'reject'; + permissionBridge.handleUserDecision(args.requestId, args.optionId, kind as any); + return { success: true, result: { requestId: args.requestId, optionId: args.optionId } }; + } catch (err) { + return { success: false, error: classifyError(err), details: safeErrorMessage(err) }; + } + }, + }, + { signal: controller.signal }, + ); + + return { dispose: () => controller.abort() }; +} diff --git a/packages/ai-native/src/browser/acp/webmcp-utils.ts b/packages/ai-native/src/browser/acp/webmcp-utils.ts new file mode 100644 index 0000000000..b5afe559b9 --- /dev/null +++ b/packages/ai-native/src/browser/acp/webmcp-utils.ts @@ -0,0 +1,82 @@ +import { Injector, Token } from '@opensumi/di'; + +export type ErrorCode = + | 'SERVICE_UNAVAILABLE' + | 'TOOL_NOT_LOADED' + | 'TOOL_NOT_FOUND' + | 'PERMISSION_DENIED' + | 'ABORTED' + | 'RPC_TIMEOUT' + | 'DI_ERROR' + | 'FILE_NOT_FOUND' + | 'FILE_EXISTS' + | 'INVALID_INPUT' + | 'IS_DIRECTORY' + | 'NOT_A_DIRECTORY' + | 'EXECUTION_ERROR'; + +export interface WebMcpToolResult { + success: boolean; + result?: unknown; + error?: string; + details?: string; +} + +export function tryGetService(container: Injector, token: Token | symbol): T | null { + try { + return container.get(token) as T; + } catch { + return null; + } +} + +export function classifyError(err: unknown): ErrorCode { + if (err instanceof Error) { + const msg = err.message.toLowerCase(); + if (msg.includes('timeout') || msg.includes('timed out')) { + return 'RPC_TIMEOUT'; + } + if (msg.includes('permission') || msg.includes('forbidden')) { + return 'PERMISSION_DENIED'; + } + if (msg.includes('abort')) { + return 'ABORTED'; + } + if (msg.includes('not found') || msg.includes('enoent')) { + return 'FILE_NOT_FOUND'; + } + if (msg.includes('already exists') || msg.includes('eexist')) { + return 'FILE_EXISTS'; + } + if (msg.includes('di') || msg.includes('injector')) { + return 'DI_ERROR'; + } + } + return 'EXECUTION_ERROR'; +} + +const SENSITIVE_PATTERNS = [ + /(?:token|key|secret|password|auth)["\s]*[:=]\s*["']?[^"'`\s,}]+/gi, + /sk-[a-zA-Z0-9]{20,}/g, + /ghp_[a-zA-Z0-9]{30,}/g, +]; + +export function safeErrorMessage(err: unknown, maxLen = 200): string { + let msg = err instanceof Error ? err.message : String(err); + for (const pattern of SENSITIVE_PATTERNS) { + msg = msg.replace(pattern, '[REDACTED]'); + } + return msg.length > maxLen ? msg.slice(0, maxLen) + '...' : msg; +} + +export function successResult(result: unknown): WebMcpToolResult { + return { success: true, result }; +} + +export function errorResult(error: ErrorCode, err: unknown): WebMcpToolResult { + return { success: false, error, details: safeErrorMessage(err) }; +} + +export function serviceUnavailableResult(serviceName: string): WebMcpToolResult { + return { success: false, error: 'SERVICE_UNAVAILABLE', details: `Service ${serviceName} is not available` }; +} diff --git a/packages/ai-native/src/browser/ai-core.contribution.ts b/packages/ai-native/src/browser/ai-core.contribution.ts index bb6273d098..93f13449fb 100644 --- a/packages/ai-native/src/browser/ai-core.contribution.ts +++ b/packages/ai-native/src/browser/ai-core.contribution.ts @@ -52,7 +52,6 @@ import { IBrowserCtxMenu } from '@opensumi/ide-core-browser/lib/menu/next/render import { AI_NATIVE_SETTING_GROUP_TITLE, ChatFeatureRegistryToken, - ChatHistoryRegistryToken, ChatInputRegistryToken, ChatRenderRegistryToken, ChatServiceToken, @@ -70,6 +69,7 @@ import { StorageProvider, TerminalRegistryToken, URI, + WebMcpGroupRegistryToken, isUndefined, runWhenIdle, } from '@opensumi/ide-core-common'; @@ -111,18 +111,22 @@ import { MCP_SERVER_TYPE } from '../common/types'; import { AcpChatInput } from './acp/components/AcpChatInput'; import { AcpChatMentionInput } from './acp/components/AcpChatMentionInput'; +import { registerFileWebMCPTools } from './acp/webmcp-file-tools.registry'; +import { WebMcpGroupRegistry } from './acp/webmcp-group-registry'; +import { createEditorGroup } from './acp/webmcp-groups/editor.webmcp-group'; +import { createFileGroup } from './acp/webmcp-groups/file.webmcp-group'; +import { createTerminalGroup } from './acp/webmcp-groups/terminal.webmcp-group'; +import { registerAcpWebMCPTools } from './acp/webmcp-tools.registry'; import { ChatEditSchemeDocumentProvider } from './chat/chat-edit-resource'; import { ChatManagerService } from './chat/chat-manager.service'; import { ChatMultiDiffResolver } from './chat/chat-multi-diff-source'; import { ChatProxyService } from './chat/chat-proxy.service'; import { ChatService } from './chat/chat.api.service'; -import { IChatHistoryRegistry } from './chat/chat.history.registry'; import { IChatInputRegistry } from './chat/chat.input.registry'; import { ChatInternalService } from './chat/chat.internal.service'; import { AIChatView } from './chat/chat.view'; import { AIChatViewACP } from './chat/chat.view.acp'; import { IChatViewRegistry } from './chat/chat.view.registry'; -import ChatHistoryACP from './components/ChatHistory.acp'; import { ChatInput } from './components/ChatInput'; import { ChatMentionInput } from './components/ChatMentionInput'; import { CodeActionSingleHandler } from './contrib/code-action/code-action.handler'; @@ -233,9 +237,6 @@ export class AINativeBrowserContribution @Autowired(ChatViewRegistryToken) private readonly chatViewRegistry: IChatViewRegistry; - @Autowired(ChatHistoryRegistryToken) - private readonly chatHistoryRegistry: IChatHistoryRegistry; - @Autowired(ResolveConflictRegistryToken) private readonly resolveConflictRegistry: IResolveConflictRegistry; @@ -329,6 +330,9 @@ export class AINativeBrowserContribution @Autowired() private readonly chatMultiDiffResolver: ChatMultiDiffResolver; + private webMCPDisposable: IDisposable | undefined; + private fileWebMCPDisposable: IDisposable | undefined; + constructor() { this.registerFeature(); } @@ -490,6 +494,17 @@ export class AINativeBrowserContribution if (supportsMCP) { this.initMCPServers(); } + + // Register WebMCP tools — must be in a contribution's onDidStart + // so it's actually called by the ClientApp lifecycle + this.webMCPDisposable = registerAcpWebMCPTools(this.injector); + this.fileWebMCPDisposable = registerFileWebMCPTools(this.injector); + + // Register WebMCP groups for ACP extension methods + const groupRegistry = this.injector.get(WebMcpGroupRegistryToken); + groupRegistry.registerGroup(createFileGroup(this.injector)); + groupRegistry.registerGroup(createTerminalGroup(this.injector)); + groupRegistry.registerGroup(createEditorGroup(this.injector)); }); } @@ -660,13 +675,6 @@ export class AINativeBrowserContribution when: () => this.aiNativeConfigService.capabilities.supportsAgentMode, }); - this.chatHistoryRegistry.registerChatHistory({ - id: 'acp-chat-history', - component: ChatHistoryACP, - priority: 200, - when: () => this.aiNativeConfigService.capabilities.supportsAgentMode, - }); - this.chatViewRegistry.registerChatView({ id: 'default-chat-view', component: AIChatView, diff --git a/packages/ai-native/src/browser/chat/acp-chat-agent.ts b/packages/ai-native/src/browser/chat/acp-chat-agent.ts index 9a79b39817..b9f09b1171 100644 --- a/packages/ai-native/src/browser/chat/acp-chat-agent.ts +++ b/packages/ai-native/src/browser/chat/acp-chat-agent.ts @@ -1,5 +1,5 @@ import { Autowired, Injectable } from '@opensumi/di'; -import { PreferenceService } from '@opensumi/ide-core-browser'; +import { ILogger, PreferenceService } from '@opensumi/ide-core-browser'; import { AIBackSerivcePath, CancellationToken, @@ -11,6 +11,7 @@ import { IApplicationService, IChatProgress, MCPConfigServiceToken, + ThreadStatus, } 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'; @@ -29,6 +30,7 @@ import { } from '../../common/index'; import { MCPConfigService } from '../mcp/config/mcp-config.service'; +import { ChatManagerService } from './chat-manager.service'; import { ChatFeatureRegistry } from './chat.feature.registry'; /** @@ -68,6 +70,12 @@ export class AcpChatAgent implements IChatAgent { @Autowired(IACPConfigProvider) protected readonly configProvider: IACPConfigProvider; + @Autowired(ILogger) + protected readonly logger: ILogger; + + @Autowired(ChatManagerService) + protected readonly chatManagerService: ChatManagerService; + public id = AcpChatAgent.AGENT_ID; public get metadata(): IChatAgentMetadata { @@ -100,6 +108,12 @@ export class AcpChatAgent implements IChatAgent { const agent = this.chatAgentService.getAgent(AcpChatAgent.AGENT_ID); const disabledTools = await this.mcpConfigService.getDisabledTools(); + this.logger.log( + `[ACP Chat] getRequestOptions: model=${model}, modelId=${modelId}, apiKey=${ + apiKey ? apiKey.slice(0, 8) + '***' : '(empty)' + }, baseURL=${baseURL}, maxTokens=${maxTokens}`, + ); + return { clientId: this.applicationService.clientId, model, @@ -152,22 +166,31 @@ export class AcpChatAgent implements IChatAgent { try { const config = await this.configProvider.resolveConfig(); - const stream = await this.aiBackService.requestStream( - prompt, - { - requestId: request.requestId, - sessionId, - history: [lastmessage], - images: request.images, - ...(await this.getRequestOptions()), - agentSessionConfig: config, - }, - token, + this.logger.log(`[ACP Chat] invoke: sessionId=${sessionId}, config=${JSON.stringify(config)}`); + + const requestOptions = { + requestId: request.requestId, + sessionId, + history: [lastmessage], + images: request.images, + ...(await this.getRequestOptions()), + agentSessionConfig: config, + }; + this.logger.log( + `[ACP Chat] invoking aiBackService.requestStream: agentSessionConfig=${!!requestOptions.agentSessionConfig}, apiKey=${ + requestOptions.apiKey ? requestOptions.apiKey.slice(0, 8) + '***' : '(empty)' + }`, ); + const stream = await this.aiBackService.requestStream(prompt, requestOptions, token); + listenReadable(stream, { onData: (data) => { - progress(data); + if (data.kind === 'threadStatus') { + this.handleThreadStatusUpdate(data.threadStatus, data.sessionId); + } else { + progress(data); + } }, onEnd: () => { chatDeferred.resolve(); @@ -191,6 +214,16 @@ export class AcpChatAgent implements IChatAgent { return {}; } + private handleThreadStatusUpdate(status: ThreadStatus, sessionId: string): void { + // The node layer receives sessionId without the 'acp:' prefix (stripped in invoke()), + // but sessionModels map keys include the prefix. Re-add it for lookup. + const lookupKey = sessionId.startsWith('acp:') ? sessionId : `acp:${sessionId}`; + const model = this.chatManagerService.getSession(lookupKey); + if (model) { + model.setThreadStatus(status); + } + } + async provideSlashCommands(): Promise { return this.chatFeatureRegistry .getAllSlashCommand() diff --git a/packages/ai-native/src/browser/chat/chat-manager.service.acp.ts b/packages/ai-native/src/browser/chat/chat-manager.service.acp.ts index 83847b128c..a449a04eb5 100644 --- a/packages/ai-native/src/browser/chat/chat-manager.service.acp.ts +++ b/packages/ai-native/src/browser/chat/chat-manager.service.acp.ts @@ -153,7 +153,7 @@ export class AcpChatManagerService extends ChatManagerService { sessionId: item.sessionId, history: new MsgHistoryManager(this.chatFeatureRegistry, item.history), modelId: item.modelId, - title: item?.title, + title: item?.title || 'New Session', }); const requests = item.requests.map( (request) => diff --git a/packages/ai-native/src/browser/chat/chat-model.ts b/packages/ai-native/src/browser/chat/chat-model.ts index df311d1fa3..21145e1192 100644 --- a/packages/ai-native/src/browser/chat/chat-model.ts +++ b/packages/ai-native/src/browser/chat/chat-model.ts @@ -3,13 +3,16 @@ import { Injectable } from '@opensumi/di'; import { Disposable, Emitter, + Event, IChatAsyncContent, IChatComponent, IChatMarkdownContent, IChatProgress, IChatReasoning, + IChatThreadStatus, IChatToolContent, IChatTreeData, + ThreadStatus, uuid, } from '@opensumi/ide-core-common'; import { MarkdownString, isMarkdownString } from '@opensumi/monaco-editor-core/esm/vs/base/common/htmlContent'; @@ -347,6 +350,32 @@ export class ChatModel extends Disposable implements IChatModel { this.#modelId = modelId; } + #threadStatus: ThreadStatus = 'idle'; + + get threadStatus(): ThreadStatus { + return this.#threadStatus; + } + + setThreadStatus(status: ThreadStatus): void { + if (this.#threadStatus === status) { + console.log('[ACP ThreadStatus RPC] setThreadStatus: skipped (same status)', { + sessionId: this.sessionId, + status, + }); + return; + } + console.log('[ACP ThreadStatus RPC] setThreadStatus:', { + sessionId: this.sessionId, + from: this.#threadStatus, + to: status, + }); + this.#threadStatus = status; + this._onThreadStatusChange.fire(status); + } + + private _onThreadStatusChange = new Emitter(); + public readonly onThreadStatusChange: Event = this._onThreadStatusChange.event; + private processMemorySummaries(): CoreMessage[] { const memorySummaries = this.history.getMemorySummaries(); if (memorySummaries.length === 0) { @@ -520,6 +549,7 @@ export class ChatModel extends Disposable implements IChatModel { override dispose(): void { super.dispose(); + this._onThreadStatusChange.dispose(); this.#requests.forEach((r) => r.response.dispose()); } diff --git a/packages/ai-native/src/browser/chat/chat.internal.service.acp.ts b/packages/ai-native/src/browser/chat/chat.internal.service.acp.ts index 96179877d7..d4405d3ecc 100644 --- a/packages/ai-native/src/browser/chat/chat.internal.service.acp.ts +++ b/packages/ai-native/src/browser/chat/chat.internal.service.acp.ts @@ -3,6 +3,8 @@ import { AINativeConfigService } from '@opensumi/ide-core-browser'; import { AvailableCommand, Emitter, Event } from '@opensumi/ide-core-common'; import { IMessageService } from '@opensumi/ide-overlay'; +import { AcpPermissionBridgeService } from '../acp/permission-bridge.service'; + import { AcpChatManagerService } from './chat-manager.service.acp'; import { ChatModel } from './chat-model'; import { ChatInternalService } from './chat.internal.service'; @@ -15,6 +17,9 @@ export class AcpChatInternalService extends ChatInternalService { @Autowired(IMessageService) private messageService: IMessageService; + @Autowired(AcpPermissionBridgeService) + private permissionBridgeService: AcpPermissionBridgeService; + private readonly _onModeChange = new Emitter(); public readonly onModeChange: Event = this._onModeChange.event; @@ -29,6 +34,10 @@ export class AcpChatInternalService extends ChatInternalService { private availableCommands: AvailableCommand[] = []; + private stripAcpPrefix(sessionId: string): string { + return sessionId.startsWith('acp:') ? sessionId.slice(4) : sessionId; + } + getAvailableCommands(): AvailableCommand[] { return this.availableCommands; } @@ -76,6 +85,9 @@ export class AcpChatInternalService extends ChatInternalService { const acpManager = this.chatManagerService as AcpChatManagerService; this.setAvailableCommands(acpManager.getAvailableCommands()); this._onSessionModelChange.fire(this._sessionModel); + // Notify permission bridge of session change + const rawSessionId = this.stripAcpPrefix(this._sessionModel.sessionId); + this.permissionBridgeService.setActiveSession(rawSessionId); this._onChangeSession.fire(this._sessionModel.sessionId); this._onSessionLoadingChange.fire(false); } @@ -86,12 +98,19 @@ export class AcpChatInternalService extends ChatInternalService { throw new Error('No active session'); } this._onWillClearSession.fire(sessionId); + const clearedSessionId = + this._sessionModel && sessionId === this._sessionModel.sessionId ? this.stripAcpPrefix(sessionId) : undefined; this.chatManagerService.clearSession(sessionId); + if (clearedSessionId) { + this.permissionBridgeService.clearSessionDialogs(clearedSessionId); + } if (this._sessionModel && sessionId === this._sessionModel.sessionId) { this._sessionModel = await this.chatManagerService.startSession(); const acpManager = this.chatManagerService as AcpChatManagerService; this.setAvailableCommands(acpManager.getAvailableCommands()); this._onSessionModelChange.fire(this._sessionModel); + const rawSessionId = this.stripAcpPrefix(this._sessionModel.sessionId); + this.permissionBridgeService.setActiveSession(rawSessionId); } if (this._sessionModel) { this._onChangeSession.fire(this._sessionModel.sessionId); @@ -124,6 +143,9 @@ export class AcpChatInternalService extends ChatInternalService { return; } this._sessionModel = updatedSession; + // Notify permission bridge of session change + const rawSessionId = this.stripAcpPrefix(sessionId); + this.permissionBridgeService.setActiveSession(rawSessionId); this.setAvailableCommands(acpManager.getAvailableCommands()); this._onSessionModelChange.fire(this._sessionModel); this._onChangeSession.fire(this._sessionModel.sessionId); @@ -137,6 +159,7 @@ export class AcpChatInternalService extends ChatInternalService { } override dispose(): void { + this.permissionBridgeService.setActiveSession(undefined); this._onModeChange.dispose(); this._onSessionLoadingChange.dispose(); this._onSessionModelChange.dispose(); diff --git a/packages/ai-native/src/browser/chat/chat.view.acp.tsx b/packages/ai-native/src/browser/chat/chat.view.acp.tsx index bfccf6c5ac..f5682d2daa 100644 --- a/packages/ai-native/src/browser/chat/chat.view.acp.tsx +++ b/packages/ai-native/src/browser/chat/chat.view.acp.tsx @@ -7,6 +7,7 @@ import { AppConfig, LabelService, getIcon, + localize, useInjectable, useUpdateOnEvent, } from '@opensumi/ide-core-browser'; @@ -19,7 +20,6 @@ import { CancellationToken, CancellationTokenSource, ChatFeatureRegistryToken, - ChatHistoryRegistryToken, ChatInputRegistryToken, ChatMessageRole, ChatRenderRegistryToken, @@ -32,7 +32,6 @@ import { IChatContent, URI, formatLocalize, - localize, path, uuid, } from '@opensumi/ide-core-common'; @@ -51,10 +50,11 @@ import { } from '../../common/llm-context'; import { CodeBlockData } from '../../common/types'; import { cleanAttachedTextWrapper } from '../../common/utils'; +import ChatHistory, { IChatHistoryItem } from '../acp/components/AcpChatHistory'; import { AcpChatViewWrapper } from '../acp/components/AcpChatViewWrapper'; +import { AcpPermissionBridgeService } from '../acp/permission-bridge.service'; import { FileChange, FileListDisplay } from '../components/ChangeList'; import { CodeBlockWrapperInput } from '../components/ChatEditor'; -import ChatHistory, { IChatHistoryItem } from '../components/ChatHistory'; import { ChatInput } from '../components/ChatInput'; import { ChatMarkdown } from '../components/ChatMarkdown'; import { ChatNotify, ChatReply } from '../components/ChatReply'; @@ -64,7 +64,7 @@ import { WelcomeMessage } from '../components/WelcomeMsg'; import { BaseApplyService } from '../mcp/base-apply.service'; import { ChatViewHeaderRender, IMCPServerRegistry, TSlashCommandCustomRender, TokenMCPServerRegistry } from '../types'; -import { ChatRequestModel, ChatSlashCommandItemModel } from './chat-model'; +import { ChatModel, ChatRequestModel, ChatSlashCommandItemModel } from './chat-model'; import { ChatProxyService } from './chat-proxy.service'; import { ChatService } from './chat.api.service'; import { ChatFeatureRegistry } from './chat.feature.registry'; @@ -891,7 +891,7 @@ export const AIChatViewACPContent = () => {
- {!hasUserSentMessage && chatRenderRegistry.chatWelcomePageRender ? ( + {!hasUserSentMessage && messageListData.length <= 1 && chatRenderRegistry.chatWelcomePageRender ? ( React.createElement(chatRenderRegistry.chatWelcomePageRender, { onSend: handleSend, agentId, @@ -967,7 +967,7 @@ export const AIChatViewACPContent = () => { disableModelSelector={sessionModelId !== undefined || loading} sessionModelId={sessionModelId} agentCwd={appConfig.workspaceDir} - placeholder='message claude-agent-acp @to include context, / for command' + placeholder={localize('aiNative.chat.input.placeholder.acp')} />
@@ -986,11 +986,11 @@ export function DefaultChatViewHeaderACP({ const aiChatService = useInjectable(IChatInternalService); const messageService = useInjectable(IMessageService); const chatFeatureRegistry = useInjectable(ChatFeatureRegistryToken); - const chatRenderRegistry = useInjectable(ChatRenderRegistryToken); - const chatHistoryRegistry = useInjectable(ChatHistoryRegistryToken); + const permissionBridgeService = useInjectable(AcpPermissionBridgeService); const [historyList, setHistoryList] = React.useState([]); const [currentTitle, setCurrentTitle] = React.useState(''); + const [pendingPermissionBadge, setPendingPermissionBadge] = React.useState(0); const handleNewChat = React.useCallback(() => { if (aiChatService.sessionModel?.history.getMessages().length > 0) { try { @@ -1038,6 +1038,24 @@ export function DefaultChatViewHeaderACP({ const latestSummaryRequestRef = React.useRef(0); React.useEffect(() => { + const toDispose = new DisposableCollection(); + const sessionListenIds = new Set(); + const subscribedSessionIds = new Set(); + + const subscribeThreadStatus = (model: ChatModel) => { + if (subscribedSessionIds.has(model.sessionId)) { + return; + } + subscribedSessionIds.add(model.sessionId); + toDispose.push( + model.onThreadStatusChange((status) => { + setHistoryList((prev) => + prev.map((item) => (item.id === model.sessionId ? { ...item, threadStatus: status } : item)), + ); + }), + ); + }; + const getHistoryList = async () => { const currentMessages = aiChatService.sessionModel?.history.getMessages(); const latestUserMessage = [...currentMessages].find((m) => m.role === ChatMessageRole.User); @@ -1067,27 +1085,48 @@ export function DefaultChatViewHeaderACP({ } } + const sessions = aiChatService.getSessions(); + for (const session of sessions) { + subscribeThreadStatus(session); + } + setHistoryList( - aiChatService.getSessions().map((session) => { + sessions.map((session) => { 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; return { id: session.sessionId, title, updatedAt, - // TODO: 后续支持 loading: false, + threadStatus: session.threadStatus, + hasPendingPermission: permissionBridgeService.hasPendingForSession(session.sessionId), }; }), ); }; getHistoryList(); - const toDispose = new DisposableCollection(); - const sessionListenIds = new Set(); + + // Subscribe to pending permission count changes + const refreshBadge = () => { + setPendingPermissionBadge(permissionBridgeService.getPendingCountExcludingActive()); + }; + toDispose.push( + permissionBridgeService.onPendingCountChange(() => { + refreshBadge(); + getHistoryList(); + }), + ); + toDispose.push( + permissionBridgeService.onActiveSessionChange(() => { + refreshBadge(); + }), + ); + refreshBadge(); + toDispose.push( aiChatService.onChangeSession((sessionId) => { getHistoryList(); @@ -1114,38 +1153,17 @@ export function DefaultChatViewHeaderACP({ return (
- {(() => { - // 1. 优先使用 ChatHistoryRegistry 注册的历史组件(按优先级 + when 条件匹配) - const activeHistory = chatHistoryRegistry.getActiveChatHistory(); - if (activeHistory) { - const ChatHistoryComponent = activeHistory.component; - return ( - {}} - /> - ); - } - // 2. 降级使用默认 ChatHistory 组件 - return ( - {}} - /> - ); - })()} + {}} + /> { await this.workspaceService.whenReady; const agentType = getDefaultAgentType(this.preferenceService); const agentConfig = getAgentConfig(this.preferenceService, agentType); const workspaceDir = await pickWorkspaceDir(this.workspaceService, this.quickPick, this.messageService); - return { ...agentConfig, workspaceDir }; + const mcpServers = await this.mcpConfigService.getACPServers(); + + return buildAcpAgentProcessConfig({ + agentId: agentType, + registration: { + command: agentConfig.command, + args: agentConfig.args, + cwd: workspaceDir, + }, + userPreferences: { + nodePath: this.preferenceService.get('ai-native.acp.nodePath', ''), + agents: this.preferenceService.get('ai-native.acp.agents', {}), + }, + mcpServers, + }); } } diff --git a/packages/ai-native/src/browser/components/ChatHistory.acp.tsx b/packages/ai-native/src/browser/components/ChatHistory.acp.tsx deleted file mode 100644 index 8a0fde7ef9..0000000000 --- a/packages/ai-native/src/browser/components/ChatHistory.acp.tsx +++ /dev/null @@ -1,295 +0,0 @@ -import cls from 'classnames'; -import React, { FC, memo, useCallback, useEffect, useRef, useState } from 'react'; - -import { Icon, Input, Loading, Popover, PopoverPosition, PopoverTriggerType, getIcon } from '@opensumi/ide-components'; -import { localize } from '@opensumi/ide-core-browser'; -import { EnhanceIcon } from '@opensumi/ide-core-browser/lib/components/ai-native'; - -import styles from './acp/chat-history.module.less'; - -export interface IChatHistoryItem { - id: string; - title: string; - updatedAt: number; - loading: boolean; -} - -export interface IChatHistoryProps { - title: string; - historyList: IChatHistoryItem[]; - currentId?: string; - className?: string; - historyLoading?: boolean; - onNewChat: () => void; - onHistoryItemSelect: (item: IChatHistoryItem) => void; - onHistoryItemDelete: (item: IChatHistoryItem) => void; - onHistoryItemChange: (item: IChatHistoryItem, title: string) => void; - onHistoryPopoverVisibleChange?: (visible: boolean) => void; -} - -// 最大历史记录数 -const MAX_HISTORY_LIST = 100; - -const ChatHistoryACP: FC = memo( - ({ - title, - historyList, - currentId, - onNewChat, - onHistoryItemSelect, - onHistoryItemChange, - onHistoryItemDelete, - onHistoryPopoverVisibleChange, - historyLoading, - className, - }) => { - const [historyTitleEditable, setHistoryTitleEditable] = useState<{ - [key: string]: boolean; - } | null>(null); - const [searchValue, setSearchValue] = useState(''); - const inputRef = useRef(null); - - // 处理搜索输入变化 - const handleSearchChange = useCallback( - (event: React.ChangeEvent) => { - setSearchValue(event.target.value); - }, - [searchValue], - ); - - // 处理历史记录项选择 - const handleHistoryItemSelect = useCallback( - (item: IChatHistoryItem) => { - onHistoryItemSelect(item); - setSearchValue(''); - }, - [onHistoryItemSelect, searchValue], - ); - - // 处理标题编辑 - const handleTitleEdit = useCallback( - (item: IChatHistoryItem) => { - setHistoryTitleEditable({ - [item.id]: true, - }); - }, - [historyTitleEditable], - ); - - // 处理标题编辑完成 - const handleTitleEditComplete = useCallback( - (item: IChatHistoryItem, newTitle: string) => { - setHistoryTitleEditable({ - [item.id]: false, - }); - onHistoryItemChange(item, newTitle); - }, - [onHistoryItemChange, historyTitleEditable], - ); - - // 处理标题编辑取消 - const handleTitleEditCancel = useCallback( - (item: IChatHistoryItem) => { - setHistoryTitleEditable({ - [item.id]: false, - }); - }, - [historyTitleEditable], - ); - - // 处理新建聊天 - const handleNewChat = useCallback(() => { - onNewChat(); - }, [onNewChat]); - - useEffect(() => { - if (historyTitleEditable) { - inputRef.current?.focus({ cursor: 'end' }); - } - }, [historyTitleEditable]); - - // 处理删除历史记录 - const handleHistoryItemDelete = useCallback( - (item: IChatHistoryItem) => { - onHistoryItemDelete(item); - }, - [onHistoryItemDelete], - ); - - // 获取时间标签 - const getTimeKey = useCallback((diff: number): string => { - if (diff < 60 * 60 * 1000) { - const minutes = Math.floor(diff / (60 * 1000)); - return minutes === 0 ? 'Just now' : `${minutes}m ago`; - } else if (diff < 24 * 60 * 60 * 1000) { - const hours = Math.floor(diff / (60 * 60 * 1000)); - return `${hours}h ago`; - } else if (diff < 7 * 24 * 60 * 60 * 1000) { - const days = Math.floor(diff / (24 * 60 * 60 * 1000)); - return `${days}d ago`; - } else if (diff < 30 * 24 * 60 * 60 * 1000) { - const weeks = Math.floor(diff / (7 * 24 * 60 * 60 * 1000)); - return `${weeks}w ago`; - } else if (diff < 365 * 24 * 60 * 60 * 1000) { - const months = Math.floor(diff / (30 * 24 * 60 * 60 * 1000)); - return `${months}mo ago`; - } - const years = Math.floor(diff / (365 * 24 * 60 * 60 * 1000)); - return `${years}y ago`; - }, []); - - // 格式化历史记录 - const formatHistory = useCallback( - (list: IChatHistoryItem[]) => { - const now = new Date(); - const result = [] as { key: string; items: typeof list }[]; - - list.forEach((item: IChatHistoryItem) => { - const updatedAt = new Date(item.updatedAt); - const diff = now.getTime() - updatedAt.getTime(); - const key = getTimeKey(diff); - - const existingGroup = result.find((group) => group.key === key); - if (existingGroup) { - existingGroup.items.push(item); - } else { - result.push({ key, items: [item] }); - } - }); - - return result; - }, - [getTimeKey], - ); - - // 渲染历史记录项 - const renderHistoryItem = useCallback( - (item: IChatHistoryItem) => ( -
handleHistoryItemSelect(item)} - > -
- {item.loading ? ( - - ) : ( - - )} - {!historyTitleEditable?.[item.id] ? ( - - {item.title} - - ) : ( - { - handleTitleEditComplete(item, e.target.value); - }} - onBlur={() => handleTitleEditCancel(item)} - /> - )} -
-
- { - e.preventDefault(); - e.stopPropagation(); - handleHistoryItemDelete(item); - }} - ariaLabel={localize('aiNative.operate.chatHistory.delete')} - /> -
-
- ), - [ - historyTitleEditable, - handleHistoryItemSelect, - handleTitleEditComplete, - handleTitleEditCancel, - handleTitleEdit, - handleHistoryItemDelete, - currentId, - inputRef, - ], - ); - - // 渲染历史记录列表 - const renderHistory = useCallback(() => { - const filteredList = historyList - .slice(-MAX_HISTORY_LIST) - .reverse() - .filter((item) => item.title && item.title.includes(searchValue)); - - const groupedHistoryList = formatHistory(filteredList); - - return ( -
- -
- {historyLoading ? ( -
- -
- ) : ( - groupedHistoryList.map((group) => ( -
- {group.items.map(renderHistoryItem)} -
- )) - )} -
-
- ); - }, [historyList, searchValue, formatHistory, handleSearchChange, renderHistoryItem, historyLoading]); - - // getPopupContainer 处理函数 - const getPopupContainer = useCallback((triggerNode: HTMLElement) => triggerNode.parentElement!, []); - - return ( -
-
- {title} -
-
- -
- -
-
- - - -
-
- ); - }, -); - -export default ChatHistoryACP; diff --git a/packages/ai-native/src/browser/components/ChatHistory.tsx b/packages/ai-native/src/browser/components/ChatHistory.tsx index 64f980693f..ac6290435a 100644 --- a/packages/ai-native/src/browser/components/ChatHistory.tsx +++ b/packages/ai-native/src/browser/components/ChatHistory.tsx @@ -4,6 +4,7 @@ import React, { FC, memo, useCallback, useEffect, useRef, useState } from 'react import { Icon, Input, Loading, Popover, PopoverPosition, PopoverTriggerType, getIcon } from '@opensumi/ide-components'; import { localize } from '@opensumi/ide-core-browser'; import { EnhanceIcon } from '@opensumi/ide-core-browser/lib/components/ai-native'; +import { ThreadStatus } from '@opensumi/ide-core-common'; import styles from './chat-history.module.less'; @@ -12,6 +13,8 @@ export interface IChatHistoryItem { title: string; updatedAt: number; loading: boolean; + threadStatus?: ThreadStatus; + hasPendingPermission?: boolean; } export interface IChatHistoryProps { @@ -19,6 +22,8 @@ export interface IChatHistoryProps { historyList: IChatHistoryItem[]; currentId?: string; className?: string; + historyLoading?: boolean; + pendingPermissionBadge?: number; onNewChat: () => void; onHistoryItemSelect: (item: IChatHistoryItem) => void; onHistoryItemDelete: (item: IChatHistoryItem) => void; @@ -38,6 +43,8 @@ const ChatHistory: FC = memo( onHistoryItemChange, onHistoryItemDelete, className, + pendingPermissionBadge, + historyLoading, }) => { const [historyTitleEditable, setHistoryTitleEditable] = useState<{ [key: string]: boolean; @@ -167,7 +174,14 @@ const ChatHistory: FC = memo( onClick={() => handleHistoryItemSelect(item)} >
- {item.loading ? ( + {item.hasPendingPermission && item.id !== currentId ? ( + + ) : item.loading ? ( ) : ( @@ -259,11 +273,18 @@ const ChatHistory: FC = memo( title={localize('aiNative.operate.chatHistory.title')} getPopupContainer={getPopupContainer} > -
- +
+
+ + {pendingPermissionBadge && pendingPermissionBadge > 0 ? ( + + {pendingPermissionBadge > 99 ? '99+' : pendingPermissionBadge} + + ) : null} +
0 + slashCommands.length > 0 && + text.substring(0, cursorPos - 1).trim() === '' ) { setMentionState({ active: true, @@ -624,7 +625,7 @@ export const MentionInput: React.FC< }); } - // 添加对 / 键的监听,支持在任意位置触发 slash command 菜单 + // 添加对 / 键的监听,仅当 / 是第一个非空白字符时触发 slash command 菜单 if ( e.key === '/' && !mentionState.active && @@ -633,6 +634,13 @@ export const MentionInput: React.FC< slashCommands.length > 0 ) { const cursorPos = getCursorPosition(editorRef.current); + const text = editorRef.current.textContent || ''; + + // 检查 / 之前的字符是否全是空白 + if (text.substring(0, cursorPos).trim() !== '') { + // 不是第一个非空白字符,不触发 slash 面板,但仍设置状态以支持后续过滤 + return; + } setMentionState({ active: true, diff --git a/packages/ai-native/src/browser/components/acp/chat-history.module.less b/packages/ai-native/src/browser/components/acp/chat-history.module.less index d8ef17184f..34a866bacc 100644 --- a/packages/ai-native/src/browser/components/acp/chat-history.module.less +++ b/packages/ai-native/src/browser/components/acp/chat-history.module.less @@ -16,3 +16,25 @@ justify-content: center; padding: 16px; } + +.chat_history_button_wrapper { + position: relative; + display: inline-flex; +} + +.pending_permission_badge { + position: absolute; + top: -4px; + right: -6px; + min-width: 16px; + height: 16px; + padding: 0 4px; + border-radius: 8px; + background-color: var(--notificationsErrorIcon-foreground, #e74c3c); + color: #fff; + font-size: 10px; + line-height: 16px; + text-align: center; + font-weight: 600; + pointer-events: none; +} diff --git a/packages/ai-native/src/browser/components/chat-history.module.less b/packages/ai-native/src/browser/components/chat-history.module.less index 75f0008591..a3bbf327e7 100644 --- a/packages/ai-native/src/browser/components/chat-history.module.less +++ b/packages/ai-native/src/browser/components/chat-history.module.less @@ -113,6 +113,22 @@ margin-top: 2px; border-radius: 3px; + &.chat_history_item_pending { + .chat_history_item_pending_icon { + background: var(--notificationsWarningIcon-foreground, #e6a817); + border-radius: 50%; + width: 18px; + height: 18px; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--editor-background, #1e1e1e); + font-size: 11px; + line-height: 1; + } + } + .chat_history_item_content { display: flex; align-items: center; @@ -154,3 +170,25 @@ } } } + +.chat_history_button_wrapper { + position: relative; + display: inline-flex; +} + +.pending_permission_badge { + position: absolute; + top: -4px; + right: -6px; + min-width: 16px; + height: 16px; + padding: 0 4px; + border-radius: 8px; + background-color: var(--notificationsErrorIcon-foreground, #e74c3c); + color: #fff; + font-size: 10px; + line-height: 16px; + text-align: center; + font-weight: 600; + pointer-events: none; +} diff --git a/packages/ai-native/src/browser/components/permission-dialog-widget.tsx b/packages/ai-native/src/browser/components/permission-dialog-widget.tsx index c0efbf7e2d..e2ac35fb52 100644 --- a/packages/ai-native/src/browser/components/permission-dialog-widget.tsx +++ b/packages/ai-native/src/browser/components/permission-dialog-widget.tsx @@ -16,7 +16,10 @@ export interface PermissionDialogWidgetProps { } export const PermissionDialogWidget: React.FC = ({ dialogManager, bottom }) => { - const [dialogs, setDialogs] = React.useState>([]); + const [allDialogs, setAllDialogs] = React.useState>( + [], + ); + const [activeSessionId, setActiveSessionId] = React.useState(); const [focusedIndex, setFocusedIndex] = React.useState(0); const containerRef = React.useRef(null); @@ -24,14 +27,26 @@ export const PermissionDialogWidget: React.FC = ({ React.useEffect(() => { const unsubscribe = dialogManager.subscribe((newDialogs) => { - setDialogs(newDialogs); + setAllDialogs(newDialogs); setFocusedIndex(0); }); const initialDialogs = dialogManager.getDialogs(); - setDialogs(initialDialogs); + setAllDialogs(initialDialogs); return unsubscribe; }, [dialogManager]); + React.useEffect(() => { + const disposable = permissionBridgeService.onActiveSessionChange((sessionId) => { + setActiveSessionId(sessionId); + setFocusedIndex(0); + }); + setActiveSessionId(permissionBridgeService.getActiveSession()); + return () => disposable.dispose(); + }, [permissionBridgeService]); + + // Filter dialogs for the active session only + const dialogs = activeSessionId ? allDialogs.filter((d) => d.params.sessionId === activeSessionId) : []; + React.useEffect(() => { if (dialogs.length > 0) { window.addEventListener('keydown', handleKeyboard); @@ -95,11 +110,12 @@ export const PermissionDialogWidget: React.FC = ({ className={styles.permission_dialog_container} style={{ bottom: `calc(100% + ${bottom + 8}px)` }} tabIndex={0} + data-testid='acp-permission-dialog' > -
+
{/* 标题栏 */}
-
+
! {smartTitle}
@@ -109,16 +125,21 @@ export const PermissionDialogWidget: React.FC = ({ permissionBridgeService.handleDialogClose(current.requestId); dialogManager.removeDialog(current.requestId); }} + data-testid='acp-permission-dialog-close' >
{/* 内容 */} - {shouldShowContent && params.content &&
{params.content}
} + {shouldShowContent && params.content && ( +
+ {params.content} +
+ )} {/* 选项 */} -
+
{(params.options || []).map((option, index) => { const isFocused = focusedIndex === index; return ( @@ -130,6 +151,7 @@ export const PermissionDialogWidget: React.FC = ({ dialogManager.removeDialog(current.requestId); }} onMouseEnter={() => setFocusedIndex(index)} + data-testid={`acp-permission-dialog-option-${index}`} > {index + 1} {option.name || option.optionId} diff --git a/packages/ai-native/src/browser/index.ts b/packages/ai-native/src/browser/index.ts index 3603a2cdb1..bf6c826bcd 100644 --- a/packages/ai-native/src/browser/index.ts +++ b/packages/ai-native/src/browser/index.ts @@ -20,12 +20,15 @@ import { import { AcpPermissionServicePath, AcpPermissionServiceToken, + AcpThreadStatusServicePath, + AcpWebMcpBridgePath, IACPConfigProvider, IntelligentCompletionsRegistryToken, MCPConfigServiceToken, ProblemFixRegistryToken, RulesServiceToken, TerminalRegistryToken, + WebMcpGroupRegistryToken, } from '@opensumi/ide-core-common'; import { FolderFilePreferenceProvider } from '@opensumi/ide-preferences/lib/browser/folder-file-preference-provider'; @@ -45,7 +48,13 @@ import { MCPServerManager, MCPServerManagerPath } from '../common/mcp-server-man import { ChatAgentPromptProvider, DefaultChatAgentPromptProvider } from '../common/prompts/context-prompt-provider'; import { ACPChatAgentPromptProvider } from '../common/prompts/empty-prompt-provider'; -import { AcpPermissionBridgeService, AcpPermissionRpcService } from './acp'; +import { + AcpPermissionBridgeService, + AcpPermissionRpcService, + AcpThreadStatusRpcService, + AcpWebMcpRpcService, + WebMcpGroupRegistry, +} from './acp'; import { AcpFooterContribution } from './acp/components/AcpFooterContribution'; import { AcpPermissionDialogContribution, PermissionDialogManager } from './acp/permission-dialog-container'; import { AINativeBrowserContribution } from './ai-core.contribution'; @@ -323,6 +332,19 @@ export class AINativeModule extends BrowserModule { token: AcpPermissionServiceToken, useClass: AcpPermissionRpcService, }, + { + token: AcpThreadStatusServicePath, + useClass: AcpThreadStatusRpcService, + }, + // WebMCP group registry and RPC bridge + { + token: WebMcpGroupRegistryToken, + useClass: WebMcpGroupRegistry, + }, + { + token: AcpWebMcpBridgePath, + useClass: AcpWebMcpRpcService, + }, ]; backServices = [ @@ -343,5 +365,13 @@ export class AINativeModule extends BrowserModule { servicePath: AcpPermissionServicePath, clientToken: AcpPermissionServiceToken, }, + { + servicePath: AcpThreadStatusServicePath, + clientToken: AcpThreadStatusServicePath, + }, + { + servicePath: AcpWebMcpBridgePath, + clientToken: AcpWebMcpBridgePath, + }, ]; } diff --git a/packages/ai-native/src/browser/mcp/config/mcp-config.service.ts b/packages/ai-native/src/browser/mcp/config/mcp-config.service.ts index 0f44a28bda..fd0058b929 100644 --- a/packages/ai-native/src/browser/mcp/config/mcp-config.service.ts +++ b/packages/ai-native/src/browser/mcp/config/mcp-config.service.ts @@ -11,6 +11,7 @@ import { StorageProvider, localize, } from '@opensumi/ide-core-common'; +import { EnvVariable, McpServer } from '@opensumi/ide-core-common/lib/types/ai-native/acp-types'; import { WorkbenchEditorService } from '@opensumi/ide-editor'; import { IMessageService } from '@opensumi/ide-overlay'; @@ -275,6 +276,53 @@ export class MCPConfigService extends Disposable { return undefined; } + async getACPServers(): Promise { + await this.whenReady; + const { value: mcpConfig, scope } = this.preferenceService.resolve<{ mcpServers: Record }>( + 'mcp', + { mcpServers: {} }, + undefined, + ); + + if (scope === PreferenceScope.Default) { + return []; + } + + const serverNames = Object.keys(mcpConfig?.mcpServers ?? {}); + const serverConfigs = await Promise.all(serverNames.map((name) => this.getServerConfigByName(name))); + + return serverConfigs + .filter((server): server is MCPServerDescription => !!server && server.enabled !== false) + .map((server) => this.toACPServer(server)) + .filter((server): server is McpServer => !!server); + } + + private toACPServer(server: MCPServerDescription): McpServer | undefined { + if (server.type === MCP_SERVER_TYPE.SSE) { + return { + type: 'sse', + name: server.name, + url: server.url, + headers: [], + }; + } + + if (server.type === MCP_SERVER_TYPE.STDIO) { + return { + name: server.name, + command: server.command, + args: server.args ?? [], + env: this.toACPEnv(server.env), + }; + } + + return undefined; + } + + private toACPEnv(env?: Record): EnvVariable[] { + return Object.entries(env ?? {}).map(([name, value]) => ({ name, value })); + } + getReadableServerType(type: string): string { switch (type) { case MCP_SERVER_TYPE.STDIO: diff --git a/packages/ai-native/src/browser/mcp/mcp-server-proxy.service.ts b/packages/ai-native/src/browser/mcp/mcp-server-proxy.service.ts index 5cefb196a0..c94b240a9c 100644 --- a/packages/ai-native/src/browser/mcp/mcp-server-proxy.service.ts +++ b/packages/ai-native/src/browser/mcp/mcp-server-proxy.service.ts @@ -1,5 +1,3 @@ -import { zodToJsonSchema } from 'zod-to-json-schema'; - import { Autowired, Injectable } from '@opensumi/di'; import { ILogger } from '@opensumi/ide-core-browser'; import { Emitter, Event } from '@opensumi/ide-core-common'; @@ -30,15 +28,20 @@ export class MCPServerProxyService implements IMCPServerProxyService { // 获取 OpenSumi 内部注册的 MCP tools async $getBuiltinMCPTools() { - const tools = await this.mcpServerRegistry.getMCPTools().map((tool) => - // 不要传递 handler - ({ + const tools = await this.mcpServerRegistry.getMCPTools().map((tool) => { + // Use Zod v4's built-in toJSONSchema() instead of zodToJsonSchema (v3-only) + const jsonSchema = + typeof (tool.inputSchema as any).toJSONSchema === 'function' + ? (tool.inputSchema as any).toJSONSchema() + : tool.inputSchema; + + return { name: tool.name, description: tool.description, - inputSchema: zodToJsonSchema(tool.inputSchema), + inputSchema: jsonSchema, providerName: BUILTIN_MCP_SERVER_NAME, - }), - ); + }; + }); this.logger.log('SUMI MCP tools', tools); diff --git a/packages/ai-native/src/browser/preferences/schema.ts b/packages/ai-native/src/browser/preferences/schema.ts index 69f794fea6..14528a8685 100644 --- a/packages/ai-native/src/browser/preferences/schema.ts +++ b/packages/ai-native/src/browser/preferences/schema.ts @@ -219,5 +219,40 @@ export const aiNativePreferenceSchema: PreferenceSchema = { default: '', description: '%preference.ai.native.globalRules.description%', }, + [AINativeSettingSectionsId.NodePath]: { + type: 'string', + default: '', + description: '%preference.ai-native.acp.nodePath.description%', + }, + [AINativeSettingSectionsId.AgentConfigsOverride]: { + type: 'object', + description: '%preference.ai-native.acp.agents.description%', + markdownDescription: '%preference.ai-native.acp.agents.markdownDescription%', + additionalProperties: { + type: 'object', + properties: { + command: { + type: 'string', + description: '%preference.ai-native.acp.agentConfigsOverride.command.description%', + }, + args: { + type: 'array', + items: { + type: 'string', + }, + default: [], + description: '%preference.ai-native.acp.agentConfigsOverride.args.description%', + }, + env: { + type: 'object', + additionalProperties: { + type: 'string', + }, + description: '%preference.ai-native.acp.agentConfigsOverride.env.description%', + default: {}, + }, + }, + }, + }, }, }; diff --git a/packages/ai-native/src/common/tool-invocation-registry.ts b/packages/ai-native/src/common/tool-invocation-registry.ts index 813ef582e8..7caca8989a 100644 --- a/packages/ai-native/src/common/tool-invocation-registry.ts +++ b/packages/ai-native/src/common/tool-invocation-registry.ts @@ -8,7 +8,12 @@ export const ToolParameterSchema = z.object({ description: z.string().optional(), enum: z.array(z.any()).optional(), items: z.lazy(() => ToolParameterSchema).optional(), - properties: z.record(z.lazy(() => ToolParameterSchema)).optional(), + properties: z + .record( + z.string(), + z.lazy(() => ToolParameterSchema), + ) + .optional(), required: z.array(z.string()).optional(), }); diff --git a/packages/ai-native/src/node/acp/acp-agent.service.ts b/packages/ai-native/src/node/acp/acp-agent.service.ts index 5efe6c5f17..52d4654b9b 100644 --- a/packages/ai-native/src/node/acp/acp-agent.service.ts +++ b/packages/ai-native/src/node/acp/acp-agent.service.ts @@ -1,36 +1,31 @@ import { Autowired, Injectable } from '@opensumi/di'; +import { Deferred, Disposable, Emitter, Event, IDisposable } from '@opensumi/ide-core-common'; import { - AcpCliClientServiceToken, - type AvailableCommand, - type CancelNotification, - type ContentBlock, - IAcpCliClientService, - type ListSessionsRequest, - type ListSessionsResponse, - type LoadSessionRequest, - type NewSessionRequest, - type SessionMode, - type SessionModeState, - type SessionNotification, - type SetSessionModeRequest, + AvailableCommand, + ListSessionsRequest, + ListSessionsResponse, + McpServer, + SessionInfo, + SessionNotification, } from '@opensumi/ide-core-common/lib/types/ai-native/acp-types'; import { AgentProcessConfig } 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 { normalizeAcpError } from './acp-error'; +import { + AcpThread, + AcpThreadEvent, + AcpThreadFactory, + AcpThreadFactoryToken, + AcpThreadRuntimeConfig, + ThreadStatus, +} from './acp-thread'; import { AcpTerminalHandler, AcpTerminalHandlerToken } from './handlers/terminal.handler'; +import { PermissionRoutingService, PermissionRoutingServiceToken } from './permission-routing.service'; -export interface SessionLoadResult { - sessionId: string; - processId: string; - modes: SessionMode[]; - status: AgentSessionStatus; - /** - * 从 Agent 接收到的所有 session/update 消息 - */ - historyUpdates: SessionNotification[]; -} +import type { AgentUpdate, AgentUpdateType, SimpleToolCall } from './acp-update-types'; +export { AgentUpdate, AgentUpdateType, SimpleToolCall } from './acp-update-types'; // ============================================================================ // DI Token @@ -51,541 +46,1049 @@ export interface SimpleMessage { export interface AgentSessionInfo { sessionId: string; + /** threadId of the AcpThread instance */ processId: string; - modes: SessionMode[]; + modes: Array<{ id: string; name: string }>; 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; -} - /** - * Agent 请求参数 + * Agent request parameters */ export interface AgentRequest { prompt: string; - /** ACP session/prompt 使用的 sessionId(来自 ACP Agent 的 session ID) */ + /** ACP session/prompt sessionId */ sessionId: string; images?: string[]; history?: SimpleMessage[]; } +export interface SessionLoadResult { + sessionId: string; + processId: string; + modes: Array<{ id: string; name: string }>; + status: AgentSessionStatus; + historyUpdates: SessionNotification[]; +} + +// ============================================================================ +// SDK type aliases (SDK is ESM, can't use static imports in this CJS file) +// ============================================================================ + /** - * 无状态的 ACP Agent 服务接口 + * Minimal shape matching the SDK's SetSessionConfigOptionRequest: + * ({ type: "boolean"; value: boolean } | { value: string }) & { sessionId, configId, _meta? } */ +interface SetSessionConfigOptionRequest { + sessionId: string; + configId: string; + value: boolean | string; + type?: 'boolean'; + _meta?: { [key: string]: unknown } | null; +} + +// ============================================================================ +// IAcpAgentService Interface +// ============================================================================ + export interface IAcpAgentService { /** - * 初始化 Agent 进程 - * @param config - Agent 配置 + * Initialize Agent process and create a new session */ initializeAgent(config: AgentProcessConfig): Promise; /** - * 加载已有 Agent Session + * Load an existing Agent Session */ loadSession(sessionId: string, config: AgentProcessConfig): Promise; /** - * 发送消息到 Agent(无状态) + * Send message to Agent (streaming) */ sendMessage(request: AgentRequest, config: AgentProcessConfig): SumiReadableStream; /** - * 取消请求 + * Cancel a request */ cancelRequest(sessionId: string): Promise; /** - * 停止 Agent 进程 + * Stop all Agent processes */ stopAgent(): Promise; /** - * 清理所有资源 + * Clean up all resources */ dispose(): Promise; /** - * 获取当前 Agent Session 信息 + * Get current Agent Session info */ - getSessionInfo(): AgentSessionInfo | null; + getSessionInfo(sessionId?: string): AgentSessionInfo | null; + /** + * Create a new session + */ createSession(config: AgentProcessConfig): Promise<{ sessionId: string; availableCommands: AvailableCommand[] }>; /** - * 列出所有 ACP Agent 会话 + * List all ACP Agent sessions */ listSessions(params?: ListSessionsRequest): Promise; /** - * 切换 Session 模式 + * Switch Session mode + */ + setSessionMode(params: { sessionId: string; modeId: string }): Promise; + + /** + * Load existing session, fallback to new session if load fails. + */ + loadSessionOrNew(sessionId: string, config: AgentProcessConfig): Promise; + + /** + * Set session configuration options (e.g. permission levels). + */ + setSessionConfigOption(params: { sessionId: string; configId: string; value: boolean | string }): Promise; + + /** Fork a session (create a copy based on existing session state) */ + forkSession(params: { sessionId: string; cwd?: string; mcpServers?: McpServer[] }): Promise<{ sessionId: string }>; + + /** Resume a closed session */ + resumeSession(params: { sessionId: string; cwd?: string }): Promise; + + /** Close a session without disposing the thread */ + closeSession(params: { sessionId: string }): Promise; + + /** Switch the AI model for the session */ + setSessionModel(params: { sessionId: string; model: string }): Promise; + + /** + * Release resources for a specific session (including terminals) + * By default, the thread returns to the pool for reuse. + * Pass force=true to fully dispose the thread. */ - setSessionMode(params: SetSessionModeRequest): Promise; + disposeSession(sessionId: string, force?: boolean): Promise; /** - * 释放指定 Session 的资源(包括终端等) + * Get available modes from initialize negotiation */ - disposeSession(sessionId: string): Promise; + getAvailableModes(): Promise; /** - * 获取 initialize 协商时存储的 Session 模式 + * Event fired when any session's thread status changes. + * Persists across sendMessage() calls — unlike onEvent listeners + * that only exist during stream lifetime. */ - getAvailableModes(): Promise; + readonly onThreadStatusChange: Event<{ sessionId: string; status: ThreadStatus }>; } +// ============================================================================ +// AcpAgentService — Thread Pool Implementation +// ============================================================================ + /** - * 无状态的 ACP Agent 服务 + * ACP Agent Service with Thread Pool management. * - * 设计原则: - * 1. 只维护单一 Agent 进程实例 - * 2. 负责启动/停止 Agent 进程、转发请求、流式返回响应 + * Design principles: + * 1. Manages multiple AcpThread instances, each with its own Agent process + * 2. Thread pool for reuse — threads are not disposed on session end by default + * 3. Streaming responses via SumiReadableStream + * 4. Deferred pattern for session creation (no setTimeout polling) */ @Injectable() -export class AcpAgentService implements IAcpAgentService { - @Autowired(AcpCliClientServiceToken) - private clientService: IAcpCliClientService; - - @Autowired(CliAgentProcessManagerToken) - private processManager: ICliAgentProcessManager; +export class AcpAgentService extends Disposable implements IAcpAgentService { + @Autowired(AcpThreadFactoryToken) + private threadFactory: AcpThreadFactory; @Autowired(AcpTerminalHandlerToken) private terminalHandler: AcpTerminalHandler; + @Autowired(PermissionRoutingServiceToken) + private permissionRouting: PermissionRoutingService; + @Autowired(AppConfig) private appConfig: AppConfig; @Autowired(INodeLogger) private readonly logger: INodeLogger; - // 当前 Agent Session 信息 - private sessionInfo: AgentSessionInfo | null = null; + // Session -> Thread mapping (active sessions) + private sessions = new Map(); - // 全局 Agent 进程 ID(单一实例) - private currentProcessId: string | null = null; - - // 当前活跃的通知处理器和 stream - private currentNotificationHandler: { - unsubscribe: () => void; - stream: SumiReadableStream; - sessionId: string; - } | null = null; - - // 确保初始化只执行一次 - private initializingPromise: Promise | null = null; - - // 断开事件订阅的取消函数 - private disconnectUnsubscribe: (() => void) | null = null; - - async createSession( - config: AgentProcessConfig, - ): Promise<{ sessionId: string; availableCommands: AvailableCommand[] }> { - await this.ensureConnected(config); + // Thread pool: all thread instances (active + idle/disconnected) + private threadPool: AcpThread[] = []; - // 设置临时通知处理器来收集 availableCommands - const availableCommands: AvailableCommand[] = []; - const tempHandler = (notification: SessionNotification) => { - const update = notification.update as any; - if (update?.sessionUpdate === 'available_commands_update' && Array.isArray(update.availableCommands)) { - availableCommands.push(...update.availableCommands); - } - }; + // Pool limit (configurable) + private readonly maxPoolSize = 10; - // 订阅临时通知处理器 - const unsubscribe = this.clientService.onNotification(tempHandler); + // Cached session info for backward compat (getSessionInfo without sessionId) + private lastSessionInfo: AgentSessionInfo | null = null; - try { - const res = await Promise.race([ - this.clientService.newSession({ cwd: config.workspaceDir, mcpServers: [] }), - new Promise((_, reject) => setTimeout(() => reject(new Error('Create session timeout')), 60000)), - ]); + // Persistent thread status change listeners (survives across sendMessage streams) + private threadStatusDisposables = new Map(); - // 等待延迟的 session/update 通知,增加等待时间以确保 availableCommands 通知到达 - await new Promise((resolve) => setTimeout(resolve, 2000)); + private _onThreadStatusChange = new Emitter<{ sessionId: string; status: ThreadStatus }>(); + readonly onThreadStatusChange: Event<{ sessionId: string; status: ThreadStatus }> = this._onThreadStatusChange.event; - // 根据 name 去重 - const seen = new Set(); - const deduplicated = availableCommands.filter((cmd) => { - if (seen.has(cmd.name)) { - return false; - } - seen.add(cmd.name); - return true; - }); + // ----------------------------------------------------------------------- + // Core: findOrCreateThread + // ----------------------------------------------------------------------- - return { ...res, availableCommands: deduplicated }; - } finally { - unsubscribe(); - } - } /** - * 确保 Agent 进程已连接并初始化,复用现有连接或启动新进程 + * Find or create a thread for the given sessionId. + * 1. Active session mapping exists -> return it + * 2. Pool has idle thread -> bind to session + * 3. Pool not full -> create new thread + * 4. Pool full, no idle -> throw */ - private async ensureConnected(config: AgentProcessConfig): Promise { - if (this.currentProcessId) { - return this.currentProcessId; + private async findOrCreateThread(sessionId: string, config: AgentProcessConfig): Promise { + // 1. Active session mapping exists + const existing = this.sessions.get(sessionId); + if (existing && existing.getStatus() !== 'disconnected') { + return existing; } - const { processId, stdout, stdin } = await this.processManager.startAgent( - config.command, - config.args, - config.env ?? {}, - config.workspaceDir, + // 2. Pool has idle thread (idle or awaiting_prompt, not bound to active session) + const idleThread = this.threadPool.find( + (t) => !this.hasActiveSession(t) && ['idle', 'awaiting_prompt'].includes(t.getStatus()), ); + if (idleThread) { + this.sessions.set(sessionId, idleThread); + return idleThread; + } - this.clientService.setTransport(stdout, stdin); - await this.clientService.initialize(); - this.currentProcessId = processId; - - // 订阅断开事件,自动清理上层状态 - if (this.disconnectUnsubscribe) { - this.disconnectUnsubscribe(); + // 3. Pool not full, create new + if (this.threadPool.length < this.maxPoolSize) { + const thread = this.createThreadInstance(sessionId, config); + this.threadPool.push(thread); + this.sessions.set(sessionId, thread); + return thread; } - this.disconnectUnsubscribe = this.clientService.onDisconnect(() => { - this.logger?.warn('[AcpAgentService] Connection lost, clearing state'); - this.currentProcessId = null; - this.sessionInfo = null; - this.initializingPromise = null; - }); - return processId; + // 4. Pool full, no idle — throw error + throw new Error(`Thread pool is full (${this.maxPoolSize}), no idle thread available`); } /** - * 获取当前 Agent Session 信息 + * Check if a thread is bound to any active session. */ - getSessionInfo(): AgentSessionInfo | null { - return this.sessionInfo; + private hasActiveSession(thread: AcpThread): boolean { + for (const [, t] of this.sessions) { + if (t === thread) { + return true; + } + } + return false; } - async initializeAgent(config: AgentProcessConfig): Promise { - if (this.sessionInfo && this.currentProcessId) { - return this.sessionInfo; + /** + * Create a new AcpThread instance via factory. + */ + private createThreadInstance(sessionId: string, config: AgentProcessConfig): AcpThread { + const runtimeConfig: AcpThreadRuntimeConfig = { + agentId: config.agentId, + command: config.command, + args: config.args, + env: config.env, + cwd: config.cwd, + nodePath: config.nodePath, + }; + const thread = this.threadFactory(sessionId, runtimeConfig); + this.logger.log( + `[AcpAgentService] Created new thread ${thread.threadId} for session ${sessionId}, cwd=${config.cwd}`, + ); + return thread; + } + + /** + * Find an idle thread or create a new one, without binding to a sessionId. + */ + private async findOrCreateIdleThread(config: AgentProcessConfig): Promise { + const idleThread = this.threadPool.find( + (t) => !this.hasActiveSession(t) && ['idle', 'awaiting_prompt'].includes(t.getStatus()), + ); + if (idleThread) { + return idleThread; } - if (this.initializingPromise) { - return this.initializingPromise; + if (this.threadPool.length < this.maxPoolSize) { + const runtimeConfig: AcpThreadRuntimeConfig = { + agentId: config.agentId, + command: config.command, + args: config.args, + env: config.env, + cwd: config.cwd, + nodePath: config.nodePath, + }; + const thread = this.threadFactory('', runtimeConfig); + this.threadPool.push(thread); + return thread; } - this.initializingPromise = (async () => { - const processId = await this.ensureConnected(config); + throw new Error(`Thread pool is full (${this.maxPoolSize}), no idle thread available`); + } - const newSessionRequest: NewSessionRequest = { - cwd: config.workspaceDir, - mcpServers: [], - }; + private getSessionMcpServers(thread: AcpThread, config: AgentProcessConfig): McpServer[] { + const mcpServers = config.mcpServers ?? []; + if (mcpServers.length === 0) { + return []; + } - const newSessionResponse = await this.clientService.newSession(newSessionRequest); + const mcpCapabilities = thread.agentCapabilities?.mcpCapabilities; + return mcpServers.filter((server) => { + const type = (server as { type?: string }).type; + if (type === 'http') { + const supported = mcpCapabilities?.http === true; + if (!supported) { + this.logger.warn(`[AcpAgentService] Skipping HTTP MCP server "${server.name}"; agent does not support it`); + } + return supported; + } + if (type === 'sse') { + const supported = mcpCapabilities?.sse === true; + if (!supported) { + this.logger.warn(`[AcpAgentService] Skipping SSE MCP server "${server.name}"; agent does not support it`); + } + return supported; + } + return true; + }); + } - this.sessionInfo = { - sessionId: newSessionResponse.sessionId, - processId, - modes: (newSessionResponse.modes?.availableModes ?? []) as SessionMode[], - status: 'ready', - }; + // ----------------------------------------------------------------------- + // createSession — with Deferred pattern (NOT setTimeout) + // ----------------------------------------------------------------------- - this.currentProcessId = processId; + async createSession( + config: AgentProcessConfig, + ): Promise<{ sessionId: string; availableCommands: AvailableCommand[] }> { + this.logger.log(`[AcpAgentService] createSession() — cwd=${config.cwd}, command=${config.command}`); + const poolSizeBefore = this.threadPool.length; + const thread = await this.findOrCreateIdleThread(config); + const wasExisting = this.threadPool.length === poolSizeBefore; - return this.sessionInfo; - })(); + const availableCommands: AvailableCommand[] = []; + const deferred = new Deferred(); + + const disposable = thread.onEvent((event: AcpThreadEvent) => { + if (event.type === 'session_notification') { + const update = (event.notification as any).update; + if (update?.sessionUpdate === 'available_commands_update' && Array.isArray(update.availableCommands)) { + availableCommands.push(...update.availableCommands); + deferred.resolve(); + } + } + }); + + let realSessionId: string | undefined; try { - const result = await this.initializingPromise; - return result; + if (!thread.initialized) { + await thread.initialize(config as any); + } + if (thread.needsReset) { + thread.reset(); + } + + const newSessionResponse = await thread.newSession({ + cwd: config.cwd, + mcpServers: this.getSessionMcpServers(thread, config), + } as any); + + realSessionId = newSessionResponse.sessionId; + this.sessions.set(realSessionId, thread); + this.permissionRouting.registerSession(realSessionId); + this.registerThreadStatusListener(realSessionId, thread); + + await Promise.race([ + deferred.promise, + new Promise((_, reject) => setTimeout(() => reject(new Error('Wait for commands timeout')), 5000)), + ]); + + const seen = new Set(); + const deduplicated = availableCommands.filter((cmd) => { + if (seen.has(cmd.name)) { + return false; + } + seen.add(cmd.name); + return true; + }); + + this.updateLastSessionInfo(realSessionId, thread, deduplicated); + + this.logger.log( + `[AcpAgentService] createSession() — done, sessionId=${realSessionId}, commands=${deduplicated.length}`, + ); + this.logPoolStatus('after-createSession'); + + return { sessionId: realSessionId, availableCommands: deduplicated }; + } catch (e) { + if (realSessionId) { + this.sessions.delete(realSessionId); + this.permissionRouting.unregisterSession(realSessionId); + this.unregisterThreadStatusListener(realSessionId); + } + this.logger.error(`[AcpAgentService] createSession() — failed: ${e instanceof Error ? e.message : String(e)}`); + if (!wasExisting) { + const idx = this.threadPool.indexOf(thread); + if (idx !== -1) { + this.threadPool.splice(idx, 1); + } + await thread.dispose(); + } else { + thread.reset(); + } + throw e; } finally { - this.initializingPromise = null; + disposable.dispose(); } } - /** - * 加载已有 Agent Session - */ - async loadSession(sessionId: string, config: AgentProcessConfig): Promise { - const processId = await this.ensureConnected(config); + // ----------------------------------------------------------------------- + // initializeAgent — create a session and return info + // ----------------------------------------------------------------------- - const historyUpdates: SessionNotification[] = []; - - // 设置临时通知处理器来收集 session/update - const tempHandler = (notification: SessionNotification) => { - if (notification.sessionId === sessionId && notification.update) { - historyUpdates.push(notification); - } + async initializeAgent(config: AgentProcessConfig): Promise { + const result = await this.createSession(config); + return { + sessionId: result.sessionId, + processId: this.sessions.get(result.sessionId)?.threadId || '', + modes: [], + status: 'ready', }; + } - // 订阅临时通知处理器 - const unsubscribe = this.clientService.onNotification(tempHandler); + // ----------------------------------------------------------------------- + // loadSession + // ----------------------------------------------------------------------- - const loadRequest: LoadSessionRequest = { - sessionId, - cwd: config.workspaceDir, - mcpServers: [], - }; + async loadSession(sessionId: string, config: AgentProcessConfig): Promise { + this.logger.log(`[AcpAgentService] loadSession() — sessionId=${sessionId}`); + + // 1. sessions.get(sessionId) exists -> return directly + const existingThread = this.sessions.get(sessionId); + if (existingThread && existingThread.getStatus() !== 'disconnected') { + this.permissionRouting.registerSession(sessionId); + this.registerThreadStatusListener(sessionId, existingThread); + this.logger.log( + `[AcpAgentService] loadSession() — thread already bound, threadId=${existingThread.threadId}, cwd=${existingThread.cwd}`, + ); + return this.buildSessionLoadResult(sessionId, existingThread); + } - try { - await Promise.race([ - this.clientService.loadSession(loadRequest), - new Promise((_, reject) => - setTimeout(() => reject(new Error(`Session load timeout for ${sessionId}`)), 60000), - ), - ]); + // 2. Pool has idle Thread + const idleThread = this.threadPool.find( + (t) => !this.hasActiveSession(t) && ['idle', 'awaiting_prompt'].includes(t.getStatus()), + ); + if (idleThread) { + this.logger.log( + `[AcpAgentService] loadSession() — reusing idle thread ${idleThread.threadId}, cwd=${idleThread.cwd}`, + ); + this.sessions.set(sessionId, idleThread); + this.permissionRouting.registerSession(sessionId); + this.registerThreadStatusListener(sessionId, idleThread); + try { + if (!idleThread.initialized) { + await idleThread.initialize(config as any); + } + if (idleThread.needsReset) { + idleThread.reset(); + } + await idleThread.loadSession({ + sessionId, + cwd: config.cwd, + mcpServers: this.getSessionMcpServers(idleThread, config), + } as any); + } catch (e) { + this.sessions.delete(sessionId); + this.permissionRouting.unregisterSession(sessionId); + this.unregisterThreadStatusListener(sessionId); + idleThread.reset(); + this.logger.error( + `[AcpAgentService] loadSession() — idle thread reuse failed: ${e instanceof Error ? e.message : String(e)}`, + ); + throw e; + } + return this.buildSessionLoadResult(sessionId, idleThread); + } - // 等待延迟的 session/update 通知 - await new Promise((resolve) => setTimeout(resolve, 500)); - } finally { - unsubscribe(); + // 3. Pool not full -> new Thread + if (this.threadPool.length < this.maxPoolSize) { + this.logger.log( + `[AcpAgentService] loadSession() — creating new thread (pool=${this.threadPool.length}/${this.maxPoolSize})`, + ); + const thread = this.createThreadInstance(sessionId, config); + this.threadPool.push(thread); + this.sessions.set(sessionId, thread); + this.permissionRouting.registerSession(sessionId); + this.registerThreadStatusListener(sessionId, thread); + + try { + if (!thread.initialized) { + await thread.initialize(config as any); + } + await thread.loadSession({ + sessionId, + cwd: config.cwd, + mcpServers: this.getSessionMcpServers(thread, config), + } as any); + } catch (e) { + const idx = this.threadPool.indexOf(thread); + if (idx !== -1) { + this.threadPool.splice(idx, 1); + } + this.sessions.delete(sessionId); + this.permissionRouting.unregisterSession(sessionId); + this.unregisterThreadStatusListener(sessionId); + await thread.dispose(); + throw e; + } + return this.buildSessionLoadResult(sessionId, thread); } - 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 }); + // 4. Pool full, no idle -> throw error + throw new Error(`Thread pool is full (${this.maxPoolSize}), no idle thread available`); + } + + private buildSessionLoadResult(sessionId: string, thread: AcpThread): SessionLoadResult { + const historyUpdates: SessionNotification[] = []; + // Collect existing entries as notifications for backward compat + for (const entry of thread.getEntries()) { + // Convert entries back to notification-like format (simplified) + if (entry.type === 'user_message') { + historyUpdates.push({ + sessionId, + update: { + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: entry.data.content }, + }, + } as SessionNotification); + } else if (entry.type === 'assistant_message') { + for (const chunk of entry.data.chunks) { + historyUpdates.push({ + sessionId, + update: { + sessionUpdate: 'agent_message_chunk', + content: chunk, + }, + } as SessionNotification); } } } - this.sessionInfo = { - sessionId, - processId, - modes, - status: 'ready', - }; + const modes: Array<{ id: string; name: string }> = []; - this.currentProcessId = processId; + this.updateLastSessionInfo(sessionId, thread, []); - const result: SessionLoadResult = { + return { sessionId, - processId, + processId: thread.threadId, modes, status: 'ready', historyUpdates, }; - - return result; } - /** - * 发送消息到 Agent(无状态) - */ - sendMessage(request: AgentRequest): SumiReadableStream { + // ----------------------------------------------------------------------- + // sendMessage — streaming forward + // ----------------------------------------------------------------------- + + sendMessage(request: AgentRequest, config: AgentProcessConfig): SumiReadableStream { const stream = new SumiReadableStream(); - if (!this.currentProcessId) { - stream.emitError(new Error('Agent process not initialized')); + const thread = this.sessions.get(request.sessionId); + if (!thread) { + this.logger.error(`[AcpAgentService] sendMessage() — no thread for sessionId=${request.sessionId}`); + stream.emitError(new Error(`No active session for sessionId: ${request.sessionId}`)); return stream; } - const promptBlocks = this.buildPromptBlocks(request.prompt, request.images); + // Add user message to thread entries + thread.addUserMessage(request.prompt); - const promptRequest = { - sessionId: request.sessionId, - prompt: promptBlocks, - }; + // Emit the current thread status as the first update so the browser + // always receives the status even if no status_changed event fires + // during this prompt (e.g. session was already awaiting_prompt). + const currentStatus = thread.getStatus(); + if (currentStatus) { + stream.emitData({ type: 'thread_status', content: '', threadStatus: currentStatus }); + } - const unsubscribe = this.clientService.onNotification((notification: SessionNotification) => { - if (notification.sessionId !== request.sessionId) { - return; - } + this.logger.log( + `[AcpAgentService] sendMessage() — sessionId=${request.sessionId}, thread=${thread.threadId}, entries=${ + thread.getEntries().length + }`, + ); - this.handleNotification(notification, stream); + // Subscribe thread.onEvent: session_notification -> emitData to stream + const disposables: IDisposable[] = []; + + const eventDisposable = thread.onEvent((event: AcpThreadEvent) => { + if (event.type === 'session_notification') { + const agentUpdate = thread.toAgentUpdate(event.notification); + if (agentUpdate) { + agentUpdate.threadStatus = thread.getStatus(); + stream.emitData(agentUpdate); + } + } else if (event.type === 'status_changed') { + // Emit standalone threadStatus update for status transitions that don't + // coincide with a session_notification (e.g. disconnected, errored, idle). + stream.emitData({ type: 'thread_status', content: '', threadStatus: event.status }); + } }); + disposables.push(eventDisposable); - // 流结束时清理 + // Stream onEnd / onError -> cleanup subscriptions stream.onEnd(() => { - unsubscribe(); - this.currentNotificationHandler = null; + disposables.forEach((d) => d.dispose()); }); - stream.onError((error) => { - unsubscribe(); - this.currentNotificationHandler = null; + stream.onError(() => { + disposables.forEach((d) => d.dispose()); }); - // 保存当前处理器信息 - this.currentNotificationHandler = { - unsubscribe, - stream, - sessionId: request.sessionId, - }; - - this.sendPrompt(promptRequest, stream); + // thread.prompt() -> then markAssistantComplete -> emitData('done') -> stream.end() + this.sendPrompt(thread, request, stream, disposables); return stream; } - /** - * 异步发送 prompt(内部使用) - */ private async sendPrompt( - promptRequest: { sessionId: string; prompt: ContentBlock[] }, + thread: AcpThread, + request: AgentRequest, stream: SumiReadableStream, + disposables: IDisposable[], ): Promise { try { - await this.clientService.prompt(promptRequest); + const promptBlocks = this.buildPromptBlocks(request.prompt, request.images); + await thread.prompt({ + sessionId: request.sessionId, + prompt: promptBlocks, + } as any); + + thread.markAssistantComplete(); stream.emitData({ type: 'done', content: '' }); stream.end(); } catch (error) { - stream.emitError(error instanceof Error ? error : new Error(String(error))); + stream.emitError(normalizeAcpError(error)); } } - /** - * 处理通知 - * - * tool_call 通知仅用于 UI 展示,不触发权限弹窗。 - * 权限确认完全依赖 agent 发送的 session/request_permission JSON-RPC 请求(阻塞式), - * 由 AcpCliClientService.handleIncomingRequest → agentRequestHandler.handlePermissionRequest 处理。 - */ - private handleNotification(notification: SessionNotification, stream: SumiReadableStream): 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, - }); + // ----------------------------------------------------------------------- + // cancelRequest + // ----------------------------------------------------------------------- + + async cancelRequest(sessionId: string): Promise { + const thread = this.sessions.get(sessionId); + if (!thread) { + this.logger?.warn(`[AcpAgentService] cancelRequest: no thread for session ${sessionId}`); + return; + } + + try { + await thread.cancel({ sessionId } as any); + } catch (error) { + this.logger?.warn('[AcpAgentService] cancelRequest error:', error); + } + } + + // ----------------------------------------------------------------------- + // listSessions + // ----------------------------------------------------------------------- + + async listSessions(params?: ListSessionsRequest): Promise { + const sessionsMap = new Map(); + let lastNextCursor: string | undefined; + let activeThreadCount = 0; + + for (const [sessionId, thread] of this.sessions) { + if (thread.getStatus() !== 'disconnected') { + activeThreadCount++; + try { + const result = await thread.listSessions(params); + if (result?.sessions) { + for (const info of result.sessions) { + sessionsMap.set(info.sessionId, info); + } + } + // nextCursor/_meta are thread-specific; only meaningful for single-thread results + if (result?.nextCursor) { + lastNextCursor = result.nextCursor; + } + } catch (error) { + this.logger?.warn(`[AcpAgentService] listSessions error for thread ${sessionId}, cwd=${thread.cwd}:`, error); } - break; } + } - case 'agent_message_chunk': { - const content = update.content; - if (content.type === 'text') { - stream.emitData({ - type: 'message', - content: content.text, - }); + // Single active thread: preserve its cursor for pagination + // Multiple threads: cursors can't be meaningfully merged, so clear + return { + sessions: Array.from(sessionsMap.values()), + nextCursor: activeThreadCount === 1 ? lastNextCursor : undefined, + }; + } + + // ----------------------------------------------------------------------- + // setSessionMode + // ----------------------------------------------------------------------- + + async setSessionMode(params: { sessionId: string; modeId: string }): Promise { + const thread = this.sessions.get(params.sessionId); + if (!thread) { + throw new Error(`No active session for sessionId: ${params.sessionId}`); + } + + try { + await thread.setSessionMode({ + sessionId: params.sessionId, + modeId: params.modeId, + } as any); + } catch (error) { + this.logger?.warn(`[AcpAgentService] setSessionMode error for session ${params.sessionId}:`, error); + throw error; + } + } + + // ----------------------------------------------------------------------- + // loadSessionOrNew — with fallback + // ----------------------------------------------------------------------- + + async loadSessionOrNew(sessionId: string, config: AgentProcessConfig): Promise { + this.logger.log(`[AcpAgentService] loadSessionOrNew() — sessionId=${sessionId}`); + + const existingThread = this.sessions.get(sessionId); + if (existingThread && existingThread.getStatus() !== 'disconnected') { + return this.buildSessionLoadResult(sessionId, existingThread); + } + + const poolSizeBefore = this.threadPool.length; + const thread = await this.findOrCreateThread(sessionId, config); + this.permissionRouting.registerSession(sessionId); + this.registerThreadStatusListener(sessionId, thread); + const wasExisting = this.threadPool.length === poolSizeBefore; + + try { + if (!thread.initialized) { + await thread.initialize(config as any); + } + if (thread.needsReset) { + thread.reset(); + } + await thread.loadSessionOrNew({ + sessionId, + cwd: config.cwd, + mcpServers: this.getSessionMcpServers(thread, config), + } as any); + return this.buildSessionLoadResult(sessionId, thread); + } catch (e) { + this.sessions.delete(sessionId); + this.permissionRouting.unregisterSession(sessionId); + this.unregisterThreadStatusListener(sessionId); + if (!wasExisting) { + const idx = this.threadPool.indexOf(thread); + if (idx !== -1) { + this.threadPool.splice(idx, 1); } - break; + await thread.dispose(); + } else { + thread.reset(); } + throw e; + } + } - case 'tool_call': { - // tool_call 通知仅用于 UI 展示,不触发权限弹窗 - // 权限由 agent 通过 session/request_permission 请求阻塞式处理 - stream.emitData({ - type: 'tool_call', - content: update.title || '', - toolCall: { - name: update.title || '', - input: (update.rawInput as Record) || {}, - }, - }); - break; - } + // ----------------------------------------------------------------------- + // setSessionConfigOption + // ----------------------------------------------------------------------- - 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; + async setSessionConfigOption(params: { + sessionId: string; + configId: string; + value: boolean | string; + }): Promise { + const thread = this.sessions.get(params.sessionId); + if (!thread) { + throw new Error(`No active session for sessionId: ${params.sessionId}`); + } + try { + // SDK uses a discriminated union: { type: "boolean"; value: boolean } | { value: string } + // We infer the correct variant from the value's runtime type. + const request: SetSessionConfigOptionRequest = { + sessionId: params.sessionId, + configId: params.configId, + value: params.value, + }; + if (typeof params.value === 'boolean') { + request.type = 'boolean'; } - default: - this.logger?.log(`Unhandled session update type: ${update.sessionUpdate}`); - break; + await thread.setSessionConfigOption(request as any); + } catch (error) { + this.logger?.warn(`[AcpAgentService] setSessionConfigOption error for session ${params.sessionId}:`, error); + throw error; } } - /** - * 取消请求 - */ - async cancelRequest(sessionId: string): Promise { - if (!this.currentProcessId) { - this.logger?.warn('cancelRequest: Agent process not initialized'); - return; + // ----------------------------------------------------------------------- + // forkSession + // ----------------------------------------------------------------------- + + async forkSession(params: { + sessionId: string; + cwd?: string; + mcpServers?: McpServer[]; + }): Promise<{ sessionId: string }> { + const thread = this.sessions.get(params.sessionId); + if (!thread) { + throw new Error(`No active session for sessionId: ${params.sessionId}`); + } + try { + const response = await thread.unstable_forkSession({ + sessionId: params.sessionId, + cwd: params.cwd, + mcpServers: params.mcpServers, + } as any); + return { sessionId: response.sessionId }; + } catch (error) { + this.logger?.warn(`[AcpAgentService] forkSession error for session ${params.sessionId}:`, error); + throw error; } + } - const cancelNotification: CancelNotification = { - sessionId, - }; + // ----------------------------------------------------------------------- + // resumeSession + // ----------------------------------------------------------------------- + async resumeSession(params: { sessionId: string; cwd?: string }): Promise { + const thread = this.sessions.get(params.sessionId); + if (!thread) { + throw new Error(`No active session for sessionId: ${params.sessionId}`); + } try { - await this.clientService.cancel(cancelNotification); - } catch (error) {} + await thread.unstable_resumeSession({ sessionId: params.sessionId, cwd: params.cwd ?? thread.cwd }); + } catch (error) { + this.logger?.warn(`[AcpAgentService] resumeSession error for session ${params.sessionId}:`, error); + throw error; + } } - async listSessions(params?: ListSessionsRequest): Promise { - return this.clientService.listSessions(params); + // ----------------------------------------------------------------------- + // closeSession + // ----------------------------------------------------------------------- + + async closeSession(params: { sessionId: string }): Promise { + const thread = this.sessions.get(params.sessionId); + if (!thread) { + throw new Error(`No active session for sessionId: ${params.sessionId}`); + } + try { + await thread.unstable_closeSession({ sessionId: params.sessionId } as any); + } catch (error) { + this.logger?.warn(`[AcpAgentService] closeSession error for session ${params.sessionId}:`, error); + throw error; + } } - async setSessionMode(params: SetSessionModeRequest): Promise { - await this.clientService.setSessionMode(params); + // ----------------------------------------------------------------------- + // setSessionModel + // ----------------------------------------------------------------------- + + async setSessionModel(params: { sessionId: string; model: string }): Promise { + const thread = this.sessions.get(params.sessionId); + if (!thread) { + throw new Error(`No active session for sessionId: ${params.sessionId}`); + } + try { + await thread.unstable_setSessionModel({ sessionId: params.sessionId, model: params.model } as any); + } catch (error) { + this.logger?.warn(`[AcpAgentService] setSessionModel error for session ${params.sessionId}:`, error); + throw error; + } } - async disposeSession(sessionId: string): Promise { + // ----------------------------------------------------------------------- + // disposeSession — default returns thread to pool, force disposes it + // ----------------------------------------------------------------------- + + async disposeSession(sessionId: string, force = false): Promise { + const thread = this.sessions.get(sessionId); + this.logger.log(`[AcpAgentService] disposeSession() — sessionId=${sessionId}, force=${force}`); + + // Release terminals await this.terminalHandler.releaseSessionTerminals(sessionId); + + if (force && thread) { + // Force dispose: release terminals + dispose thread + this.logger.log( + `[AcpAgentService] disposeSession() — force disposing thread ${thread.threadId}, cwd=${thread.cwd}`, + ); + await thread.dispose(); + const idx = this.threadPool.indexOf(thread); + if (idx !== -1) { + this.threadPool.splice(idx, 1); + } + } + + // Default: just remove from session mapping, thread returns to pool + this.permissionRouting.unregisterSession(sessionId); + this.unregisterThreadStatusListener(sessionId); + this.sessions.delete(sessionId); + this.logPoolStatus('after-disposeSession'); } - async getAvailableModes() { - return this.clientService.getSessionModes(); + // ----------------------------------------------------------------------- + // getAvailableModes + // ----------------------------------------------------------------------- + + async getAvailableModes(): Promise { + // Return modes from the most recently used thread + for (const thread of this.threadPool) { + // AcpThread stores agentCapabilities but not modes directly + // Modes come from initialize response; would need to track them + } + return null; } - /** - * 停止 Agent 进程 - */ + // ----------------------------------------------------------------------- + // getSessionInfo + // ----------------------------------------------------------------------- + + getSessionInfo(sessionId?: string): AgentSessionInfo | null { + if (sessionId) { + const thread = this.sessions.get(sessionId); + if (!thread) { + return null; + } + return { + sessionId, + processId: thread.threadId, + modes: [], + status: this.threadStatusToAgentStatus(thread.getStatus()), + }; + } + return this.lastSessionInfo; + } + + // ----------------------------------------------------------------------- + // stopAgent — dispose all threads + // ----------------------------------------------------------------------- + async stopAgent(): Promise { - if (!this.currentProcessId) { - return; + this.logger?.log( + `[AcpAgentService] stopAgent() — disposing ${this.threadPool.length} threads, ${this.sessions.size} active sessions`, + ); + + for (const thread of this.threadPool) { + try { + await thread.dispose(); + } catch (error) { + this.logger?.warn(`[AcpAgentService] Error disposing thread ${thread.threadId}, cwd=${thread.cwd}:`, error); + } } - await this.processManager.stopAgent(); + for (const sessionId of this.sessions.keys()) { + this.permissionRouting.unregisterSession(sessionId); + this.unregisterThreadStatusListener(sessionId); + } + this.threadPool = []; + this.sessions.clear(); + this.lastSessionInfo = null; + this.logPoolStatus('after-stopAgent'); + } - await this.clientService.close(); + // ----------------------------------------------------------------------- + // dispose — clean up all resources + // ----------------------------------------------------------------------- - this.sessionInfo = null; - this.currentProcessId = null; - this.initializingPromise = null; + async dispose(): Promise { + this.logger?.log('[AcpAgentService] dispose() — pool size=' + this.threadPool.length); + await this.stopAgent(); + this._onThreadStatusChange.dispose(); + this.logger?.log('[AcpAgentService] dispose() — done'); } + // ----------------------------------------------------------------------- + // Thread status change tracking + // ----------------------------------------------------------------------- + /** - * 清理所有资源 + * Register a persistent listener for thread status changes. + * Fires onThreadStatusChange for every status transition, even outside sendMessage streams. */ - async dispose(): Promise { - this.logger?.warn('[AcpAgentService] dispose called'); + private registerThreadStatusListener(sessionId: string, thread: AcpThread): void { + this.unregisterThreadStatusListener(sessionId); + this.logger.log(`[AcpAgentService] registerThreadStatusListener: sessionId=${sessionId}`); + const disposable = thread.onEvent((event: AcpThreadEvent) => { + if (event.type === 'status_changed') { + this.logger.log(`[AcpAgentService] thread status_changed: sessionId=${sessionId}, status=${event.status}`); + this._onThreadStatusChange.fire({ sessionId, status: event.status }); + } + }); + this.threadStatusDisposables.set(sessionId, disposable); + } - // 先取消断开事件订阅,防止后续清理操作触发 handler - if (this.disconnectUnsubscribe) { - this.disconnectUnsubscribe(); - this.disconnectUnsubscribe = null; + private unregisterThreadStatusListener(sessionId: string): void { + const disposable = this.threadStatusDisposables.get(sessionId); + if (disposable) { + this.logger.log(`[AcpAgentService] unregisterThreadStatusListener: sessionId=${sessionId}`); + disposable.dispose(); + this.threadStatusDisposables.delete(sessionId); } + } - if (this.currentNotificationHandler) { - this.currentNotificationHandler.stream.end(); - this.currentNotificationHandler.unsubscribe(); - this.currentNotificationHandler = null; - } + // ----------------------------------------------------------------------- + // Internal helpers + // ----------------------------------------------------------------------- - await this.stopAgent(); + /** + * Log pool status summary — call after key pool operations. + */ + private logPoolStatus(context: string): void { + const threadsInfo = this.threadPool.map((t) => ({ + id: t.threadId, + status: t.getStatus(), + sid: t.sessionId || '-', + entries: t.getEntries().length, + })); + const activeCount = this.sessions.size; + this.logger.log( + `[AcpAgentService] pool(${context}) — threads:${this.threadPool.length}/${ + this.maxPoolSize + }, active_sessions:${activeCount}, threads=[${threadsInfo + .map((t) => `${t.id}(${t.status},sid=${t.sid},entries=${t.entries})`) + .join(', ')}]`, + ); + } - await this.processManager.killAllAgents(); + private threadStatusToAgentStatus(status: string): AgentSessionStatus { + switch (status) { + case 'idle': + case 'awaiting_prompt': + return 'ready'; + case 'working': + return 'running'; + case 'disconnected': + return 'stopped'; + case 'errored': + return 'error'; + default: + return 'ready'; + } + } - this.initializingPromise = null; - this.sessionInfo = null; - this.currentProcessId = null; + private updateLastSessionInfo(sessionId: string, thread: AcpThread, _commands: AvailableCommand[]): void { + this.lastSessionInfo = { + sessionId, + processId: thread.threadId, + modes: [], + status: 'ready', + }; } - private buildPromptBlocks(input: string, images?: string[]): ContentBlock[] { - const blocks: ContentBlock[] = []; + private buildPromptBlocks(input: string, images?: string[]): Array<{ type: string; [key: string]: unknown }> { + const blocks: Array<{ type: string; [key: string]: unknown }> = []; blocks.push({ type: 'text', @@ -613,7 +1116,6 @@ export class AcpAgentService implements IAcpAgentService { return { mimeType: matches[1], base64Data: matches[2] }; } } - // 默认返回 return { mimeType: 'image/jpeg', base64Data: dataUrl }; } } 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 index 49bf5c0448..ead9e99511 100644 --- a/packages/ai-native/src/node/acp/acp-cli-back.service.ts +++ b/packages/ai-native/src/node/acp/acp-cli-back.service.ts @@ -8,10 +8,14 @@ import { IChatContent, IChatProgress, IChatReasoning, - ListSessionsRequest, + IChatThreadStatus, + IChatToolCall, + IChatToolContent, ListSessionsResponse, + McpServer, SessionNotification, SetSessionModeRequest, + ThreadStatus, } from '@opensumi/ide-core-common'; import { AgentProcessConfig } from '@opensumi/ide-core-common/lib/types/ai-native/agent-types'; import { ChatReadableStream, INodeLogger } from '@opensumi/ide-core-node'; @@ -20,14 +24,9 @@ import { SumiReadableStream } from '@opensumi/ide-utils/lib/stream'; import { BaseLanguageModel } from '../base-language-model'; import { OpenAICompatibleModel } from '../openai-compatible/openai-compatible-language-model'; -import { - AcpAgentServiceToken, - AgentRequest, - AgentSessionInfo, - AgentUpdate, - IAcpAgentService, - SimpleMessage, -} from './acp-agent.service'; +import { AcpAgentServiceToken, AgentRequest, AgentUpdate, IAcpAgentService, SimpleMessage } from './acp-agent.service'; +import { normalizeAcpError } from './acp-error'; +import { AcpThreadStatusCallerServiceToken } from './acp-thread-status-caller.service'; import type { CoreMessage } from 'ai'; @@ -100,9 +99,33 @@ export class AcpCliBackService implements IAIBackService { @Autowired(OpenAICompatibleModel) private openAICompatibleModel: OpenAICompatibleModel; + @Autowired(AcpThreadStatusCallerServiceToken) + private threadStatusCaller: any; + private isDisposing = false; - // private registerProcessExitHandlers(): void { + private threadStatusDisposable: any; + + /** + * Lazily subscribe to thread status changes from AcpAgentService + * and forward them to the browser via RPC. + */ + private ensureThreadStatusSubscription(): void { + if (this.threadStatusDisposable) { + return; + } + this.logger.log('[ACP Back] ensureThreadStatusSubscription: subscribing to onThreadStatusChange'); + this.threadStatusDisposable = this.agentService.onThreadStatusChange(({ sessionId, status }) => { + this.logger.log(`[ACP Back] onThreadStatusChange: sessionId=${sessionId}, status=${status}`); + if (this.threadStatusCaller?.notifyThreadStatusChange) { + this.threadStatusCaller.notifyThreadStatusChange(sessionId, status); + } else { + this.logger.warn('[ACP Back] onThreadStatusChange: threadStatusCaller not available'); + } + }); + } + + // registerProcessExitHandlers(): void { // process.once('SIGTERM', () => { // this.dispose().then(() => { // process.exit(0); @@ -116,21 +139,6 @@ export class AcpCliBackService implements IAIBackService { // }); // } - async createSession( - config: AgentProcessConfig, - ): Promise<{ sessionId: string; availableCommands: AvailableCommand[] }> { - 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, @@ -147,10 +155,17 @@ export class AcpCliBackService implements IAIBackService { options: IAIBackServiceOption, cancelToken?: CancellationToken, ): Promise> { + this.logger.log( + `[ACP Back] requestStream: hasAgentSessionConfig=${!!options.agentSessionConfig}, apiKey=${ + options.apiKey ? options.apiKey.slice(0, 8) + '***' : '(empty)' + }, baseURL=${options.baseURL}, sessionId=${options.sessionId}`, + ); // Fallback to OpenAI-compatible API when ACP agent is not configured if (!options.agentSessionConfig) { + this.logger.log('[ACP Back] No agentSessionConfig, falling back to OpenAI-compatible'); return this.openAIRequestStream(input, options, cancelToken); } + this.logger.log('[ACP Back] Using agent request stream'); return this.agentRequestStream(input, options, cancelToken); } @@ -159,6 +174,11 @@ export class AcpCliBackService implements IAIBackService { options: IAIBackServiceOption, cancelToken?: CancellationToken, ): Promise { + this.logger.log( + `[ACP Back] openAIRequestStream: apiKey=${ + options.apiKey ? options.apiKey.slice(0, 8) + '***' : '(empty)' + }, baseURL=${options.baseURL}`, + ); const stream = new ChatReadableStream(); try { await this.openAICompatibleModel.request(input, stream, options, cancelToken); @@ -173,6 +193,8 @@ export class AcpCliBackService implements IAIBackService { options: IAIBackServiceOption, cancelToken?: CancellationToken, ): SumiReadableStream { + this.logger.log('[ACP Back] agentRequestStream: setting up agent stream'); + this.ensureThreadStatusSubscription(); const stream = new SumiReadableStream(); this.setupAgentStream(options.agentSessionConfig!, input, options, stream, cancelToken); return stream; @@ -186,12 +208,13 @@ export class AcpCliBackService implements IAIBackService { cancelToken?: CancellationToken, ): Promise { try { - if (!options.agentSessionConfig) { - throw Error('agentSessionConfig is required'); - } + this.logger.log(`[ACP Back] setupAgentStream: config=${JSON.stringify(config)}, sessionId=${options.sessionId}`); - const sessionInfo = await this.ensureAgentInitialized(options.agentSessionConfig); - const sessionId = options.sessionId || sessionInfo.sessionId; + let sessionId = options.sessionId; + if (!sessionId) { + const result = await this.agentService.createSession(config); + sessionId = result.sessionId; + } const request: AgentRequest = { sessionId, @@ -200,6 +223,8 @@ export class AcpCliBackService implements IAIBackService { history: convertMessageHistory(options.history), }; + this.logger.log(`[ACP Back] setupAgentStream: sending message, prompt=${input.slice(0, 100)}...`); + const agentStream = this.agentService.sendMessage(request, config); cancelToken?.onCancellationRequested(async () => { @@ -208,20 +233,33 @@ export class AcpCliBackService implements IAIBackService { }); agentStream.onData((update: AgentUpdate) => { + // this.logger.log(`[ACP Back] agentStream onData: type=${update.type}`); const progress = this.convertAgentUpdateToChatProgress(update); if (progress) { stream.emitData(progress); } + if (update.threadStatus) { + // this.logger.log( + // `[ACP Back] agentStream threadStatus via stream: sessionId=${request.sessionId}, status=${update.threadStatus}`, + // ); + stream.emitData({ + kind: 'threadStatus', + threadStatus: update.threadStatus, + sessionId: request.sessionId, + } as IChatThreadStatus); + } if (update.type === 'done') { stream.end(); } }); agentStream.onError((error) => { - stream.emitError(error instanceof Error ? error : new Error(String(error))); + this.logger.error('[ACP Back] agentStream onError:', error); + stream.emitError(normalizeAcpError(error)); }); } catch (error) { - stream.emitError(error instanceof Error ? error : new Error(String(error))); + this.logger.error('[ACP Back] setupAgentStream catch:', error); + stream.emitError(normalizeAcpError(error)); } } @@ -237,15 +275,45 @@ export class AcpCliBackService implements IAIBackService { kind: 'content', content: update.content, } as IChatContent; - case 'tool_call': - return null; - case 'tool_result': + case 'tool_call': { + const toolCall: IChatToolCall = { + id: update.toolCall?.toolCallId || '', + type: 'function', + function: { + name: update.toolCall?.name || update.content, + arguments: update.toolCall?.input ? JSON.stringify(update.toolCall.input) : '', + }, + }; + return { + kind: 'toolCall', + content: toolCall, + } as IChatToolContent; + } + case 'tool_call_status': { + const label = update.toolCall?.name || 'tool'; + const statusLabel = update.toolCall?.status === 'in_progress' ? `${label} is running...` : update.content; + return { + kind: 'content', + content: statusLabel, + } as IChatContent; + } + case 'tool_result': { + // If toolCall info is available, use it; otherwise just show content + return { + kind: 'content', + content: update.content, + } as IChatContent; + } + case 'plan': return { kind: 'content', content: update.content, } as IChatContent; case 'done': return null; + case 'thread_status': + // Handled separately via update.threadStatus below + return null; default: return null; } @@ -344,22 +412,17 @@ export class AcpCliBackService implements IAIBackService { } } - async listSessions(config: AgentProcessConfig): Promise { - const listParams: ListSessionsRequest = { - cwd: config.workspaceDir, - }; - await this.ensureAgentInitialized(config); + async createSession(config: AgentProcessConfig): Promise<{ + sessionId: string; + availableCommands: AvailableCommand[]; + }> { + this.logger.log('[ACP Back] createSession called'); + return this.agentService.createSession(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 listSessions(config: AgentProcessConfig): Promise { + this.logger.log(`[ACP Back] listSessions called, cwd=${config?.cwd}`); + return this.agentService.listSessions(config?.cwd ? { cwd: config.cwd } : undefined); } async dispose(): Promise { @@ -376,4 +439,39 @@ export class AcpCliBackService implements IAIBackService { async ready(): Promise { return true; } + + async loadSessionOrNew( + config: AgentProcessConfig, + sessionId: string, + ): Promise<{ + sessionId: string; + messages: Array<{ role: 'user' | 'assistant'; content: string; timestamp?: number }>; + }> { + const result = await this.agentService.loadSessionOrNew(sessionId, config); + const messages = this.convertSessionUpdatesToMessages(result.historyUpdates); + return { sessionId, messages }; + } + + async setSessionConfigOption(sessionId: string, configId: string, value: boolean | string): Promise { + await this.agentService.setSessionConfigOption({ sessionId, configId, value }); + } + + async forkSession( + sessionId: string, + options?: { cwd?: string; mcpServers?: McpServer[] }, + ): Promise<{ sessionId: string }> { + return this.agentService.forkSession({ sessionId, ...options }); + } + + async resumeSession(sessionId: string, cwd?: string): Promise { + await this.agentService.resumeSession({ sessionId, cwd }); + } + + async closeSession(sessionId: string): Promise { + await this.agentService.closeSession({ sessionId }); + } + + async setSessionModel(sessionId: string, model: string): Promise { + await this.agentService.setSessionModel({ sessionId, model }); + } } 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 deleted file mode 100644 index a4d76392cf..0000000000 --- a/packages/ai-native/src/node/acp/acp-cli-client.service.ts +++ /dev/null @@ -1,593 +0,0 @@ -/** - * ACP CLI 客户端服务 - 基于 NDJSON 格式的 JSON-RPC 2.0 传输层实现 - */ -import { Autowired, Injectable } from '@opensumi/di'; -import { - AgentCapabilities, - AuthMethod, - AuthenticateRequest, - AuthenticateResponse, - CancelNotification, - ExtendedInitializeResponse, - IAcpCliClientService, - InitializeRequest, - ListSessionsRequest, - ListSessionsResponse, - LoadSessionRequest, - LoadSessionResponse, - NewSessionRequest, - NewSessionResponse, - PromptRequest, - PromptResponse, - SessionModeState, - SessionNotification, - SetSessionModeRequest, - SetSessionModeResponse, -} from '@opensumi/ide-core-common'; -import { INodeLogger, Implementation } from '@opensumi/ide-core-node'; - -import { AcpAgentRequestHandler, AcpAgentRequestHandlerToken } from './handlers/agent-request.handler'; - -export const ACP_PROTOCOL_VERSION = 1; - -const ACP_NOT_CONNECTED_ERROR = 'Not connected to agent process'; - -type TransportState = 'disconnected' | 'connecting' | 'connected'; - -@Injectable() -export class AcpCliClientService implements IAcpCliClientService { - private stdout: NodeJS.ReadableStream | null = null; - private stdin: NodeJS.WritableStream | null = null; - private transportState: TransportState = 'disconnected'; - 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; - - private disconnectHandlers: (() => void)[] = []; - - @Autowired(INodeLogger) - private readonly logger: INodeLogger; - - @Autowired(AcpAgentRequestHandlerToken) - private agentRequestHandler: AcpAgentRequestHandler; - - /** - * 统一的可写性检查,替代分散在各处的连接状态判断 - */ - private ensureWritable(): void { - if (this.transportState !== 'connected' || !this.stdin) { - throw new Error(ACP_NOT_CONNECTED_ERROR); - } - } - - /** - * 订阅断开事件,供上层(如 AcpAgentService)监听并清理状态 - */ - onDisconnect(handler: () => void): () => void { - this.disconnectHandlers.push(handler); - return () => { - const index = this.disconnectHandlers.indexOf(handler); - if (index > -1) { - this.disconnectHandlers.splice(index, 1); - } - }; - } - - setTransport(stdout: NodeJS.ReadableStream, stdin: NodeJS.WritableStream): void { - // 先移除旧监听器,防止旧 stdout 的 end/error 事件触发 handleDisconnect - if (this.stdout) { - this.stdout.removeAllListeners(); - } - - if (this.stdin) { - try { - this.stdin.end(); - } catch (_) {} - } - - this.transportState = 'connecting'; - - // 拒绝 pending 请求 - for (const [, pending] of this.pendingRequests) { - pending.reject(new Error(ACP_NOT_CONNECTED_ERROR)); - } - this.pendingRequests.clear(); - - // 清空请求队列并拒绝所有待处理请求 - for (const request of this.requestQueue) { - request.reject(new Error(ACP_NOT_CONNECTED_ERROR)); - } - - this.requestQueue = []; - - this.negotiatedProtocolVersion = null; - this.agentCapabilities = null; - this.agentInfo = null; - this.authMethods = []; - this.sessionModes = null; - - this.stdout = stdout; - this.stdin = stdin; - - this.stdout.on('data', (data: Buffer) => { - this.handleData(data.toString('utf8')); - }); - - 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.transportState = 'connected'; - } - - async initialize(params?: InitializeRequest): Promise { - this.ensureWritable(); - - 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.handleDisconnect(); - - this.notificationHandlers = []; - this.disconnectHandlers = []; - - if (this.stdout) { - this.stdout.removeAllListeners(); - } - - if (this.stdin) { - try { - this.stdin.end(); - } catch (_) {} - } - - this.stdout = null; - this.stdin = null; - this.buffer = ''; - } - - isConnected(): boolean { - return this.transportState === 'connected'; - } - - private pendingRequests = new Map< - string | number, - { - resolve: (value: unknown) => void; - reject: (error: Error) => void; - } - >(); - - // 请求队列,确保按顺序发送请求 - private requestQueue: Array<{ - method: string; - params: unknown; - resolve: (value: unknown) => void; - reject: (error: Error) => void; - }> = []; - private isProcessingRequest = false; - - private async sendRequest(method: string, params: unknown): Promise { - this.ensureWritable(); - - return new Promise((resolve, reject) => { - // 将请求加入队列 - this.requestQueue.push({ - method, - params, - resolve, - reject, - }); - - // 处理队列 - this.processRequestQueue(); - }); - } - - private processRequestQueue(): void { - // 如果正在处理请求或队列为空,则直接返回 - if (this.isProcessingRequest || this.requestQueue.length === 0) { - return; - } - - // 检查连接状态 - if (this.transportState !== 'connected' || !this.stdin) { - while (this.requestQueue.length > 0) { - const request = this.requestQueue.shift(); - if (request) { - request.reject(new Error(ACP_NOT_CONNECTED_ERROR)); - } - } - return; - } - - this.isProcessingRequest = true; - - // 取出队列中的第一个请求 - const request = this.requestQueue.shift(); - - if (!request) { - this.isProcessingRequest = false; - return; - } - - const id = ++this.requestId; - - this.logger?.log(`[ACP] Sending request: ${request.method} (id=${id}) ${JSON.stringify(request.params)}`); - - this.pendingRequests.set(id, { - resolve: (value: unknown) => { - this.isProcessingRequest = false; - request.resolve(value); - // 处理下一个请求 - this.processRequestQueue(); - }, - reject: (error: Error) => { - this.isProcessingRequest = false; - request.reject(error); - // 处理下一个请求 - this.processRequestQueue(); - }, - }); - - try { - const message = { jsonrpc: '2.0', id, method: request.method, params: request.params }; - const json = JSON.stringify(message); - - // 在写入前再次检查流的状态 - if (this.transportState !== 'connected' || !this.stdin || !(this.stdin as NodeJS.WritableStream).writable) { - this.pendingRequests.delete(id); - this.isProcessingRequest = false; - request.reject(new Error(ACP_NOT_CONNECTED_ERROR)); - this.processRequestQueue(); - return; - } - - this.stdin.write(json + '\n'); - this.logger?.debug(`[ACP] Sent JSON: ${json}`); - } catch (error) { - // 写入失败时,handleDisconnect 会 reject 所有 pending 请求并清空队列 - this.handleDisconnect(); - } - } - - private sendNotification(method: string, params?: unknown): void { - if (this.transportState !== 'connected' || !this.stdin) { - return; - } - - const message = { jsonrpc: '2.0', method, params }; - const json = JSON.stringify(message); - - try { - this.stdin.write(json + '\n'); - } catch (error) { - this.logger?.warn(`[ACP] Failed to send notification: ${method}`, error); - } - } - - 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, null, 2).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 { - this.logger?.warn(`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) { - try { - this.sendMessage({ - jsonrpc: '2.0', - id: message.id, - error: { code: err.code || -32603, message: err.message || `Internal error: ${JSON.stringify(message)}` }, - }); - } catch (_) { - this.logger?.warn(`[ACP] Failed to send error response for ${message.method}: disconnected`); - } - } - } - - 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 { - this.ensureWritable(); - this.stdin!.write(JSON.stringify(message) + '\n'); - } - - public handleDisconnect(): void { - if (this.transportState === 'disconnected') { - return; - } - - this.transportState = 'disconnected'; - - this.negotiatedProtocolVersion = null; - this.agentCapabilities = null; - this.agentInfo = null; - this.authMethods = []; - this.sessionModes = null; - - for (const [, pending] of this.pendingRequests) { - pending.reject(new Error(ACP_NOT_CONNECTED_ERROR)); - } - this.pendingRequests.clear(); - - for (const request of this.requestQueue) { - request.reject(new Error(ACP_NOT_CONNECTED_ERROR)); - } - this.requestQueue = []; - this.isProcessingRequest = false; - - // 通知上层(如 AcpAgentService)连接已断开 - for (const handler of [...this.disconnectHandlers]) { - try { - handler(); - } catch (e) { - this.logger?.error('[ACP] Disconnect handler error:', e); - } - } - - this.logger?.warn('[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-error.ts b/packages/ai-native/src/node/acp/acp-error.ts new file mode 100644 index 0000000000..feb1d105c3 --- /dev/null +++ b/packages/ai-native/src/node/acp/acp-error.ts @@ -0,0 +1,75 @@ +function getStringProperty(value: Record, key: string): string | undefined { + const property = value[key]; + return typeof property === 'string' && property.trim() ? property : undefined; +} + +function stringifyErrorObject(error: object): string { + const seen = new WeakSet(); + try { + return JSON.stringify(error, (_key, value) => { + if (typeof value === 'object' && value !== null) { + if (seen.has(value)) { + return '[Circular]'; + } + seen.add(value); + } + return value; + }); + } catch { + return String(error); + } +} + +export function getAcpErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + + if (typeof error === 'string') { + return error; + } + + if (error && typeof error === 'object') { + const errorRecord = error as Record; + const message = getStringProperty(errorRecord, 'message'); + if (message) { + return message; + } + + const nestedError = errorRecord.error; + if (nestedError && typeof nestedError === 'object') { + const nestedMessage = getStringProperty(nestedError as Record, 'message'); + if (nestedMessage) { + return nestedMessage; + } + } + + const text = stringifyErrorObject(error); + return text === '{}' ? String(error) : text; + } + + return String(error); +} + +export function normalizeAcpError(error: unknown): Error { + if (error instanceof Error) { + return error; + } + + const normalizedError = new Error(getAcpErrorMessage(error)); + if (error && typeof error === 'object') { + const errorRecord = error as Record; + const code = errorRecord.code; + const data = errorRecord.data; + + if (code !== undefined) { + (normalizedError as Error & { code?: unknown }).code = code; + } + if (data !== undefined) { + (normalizedError as Error & { data?: unknown }).data = data; + } + (normalizedError as Error & { cause?: unknown }).cause = error; + } + + return normalizedError; +} 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 index caabc412e7..1bd1a35f60 100644 --- a/packages/ai-native/src/node/acp/acp-permission-caller.service.ts +++ b/packages/ai-native/src/node/acp/acp-permission-caller.service.ts @@ -1,11 +1,9 @@ -import { Autowired, Injectable } from '@opensumi/di'; +import { 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, @@ -13,58 +11,63 @@ import type { RequestPermissionResponse, } from '@opensumi/ide-core-common/lib/types/ai-native/acp-types'; -export const AcpPermissionCallerManagerToken = Symbol('AcpPermissionCallerManagerToken'); +export const AcpPermissionCallerServiceToken = Symbol('AcpPermissionCallerServiceToken'); /** - * ACP Permission Caller Manager + * ACP Permission Caller Service * + * Node-side singleton that calls the browser-side permission dialog via RPC. + * + * IMPORTANT: This service exists in BOTH the parent injector (providers) AND the + * child injector per connection (backServices). The child instance gets rpcClient + * set by bindModuleBackService, but the parent instance does not. To bridge this, + * the child instance stores its RPC stub in staticRpcClient so all instances + * can use it. + * + * Each call to requestPermission() independently invokes + * this.client or the shared static RPC stub — no global lock, + * concurrent requests run independently. */ @Injectable() -export class AcpPermissionCallerManager extends RPCService implements IAcpPermissionCaller { - @Autowired(INodeLogger) - private readonly logger: INodeLogger; - +export class AcpPermissionCallerService extends RPCService { /** - * 当前活跃的 RPC 客户端(所有连接共享) - * + * Shared RPC stub for the current browser connection. + * Populated by setStaticRpcClient() after bindModuleBackService + * assigns serviceInstance.rpcClient = [stub]. + * This allows parent-injector consumers (e.g. PermissionRoutingService) + * to reach the browser-side dialog via static access. */ - private static currentRpcClient: IAcpPermissionService | null = null; - - private clientId: string | undefined; + static staticRpcClient: IAcpPermissionService | undefined; /** - * 设置连接 clientId - * - * 注意:框架调用 setConnectionClientId 后才设置 rpcClient, - * 因此需要使用微任务延迟赋值,确保 rpcClient 已经准备好 + * Set the shared static RPC client. + * Called by bindModuleBackService (or equivalent) after setting rpcClient + * on the child-injector instance, so that parent-injector consumers + * can also reach the browser-side permission dialog. */ - setConnectionClientId(clientId: string): void { - this.clientId = clientId; - - Promise.resolve().then(() => { - AcpPermissionCallerManager.currentRpcClient = this.client || null; - }); + static setStaticRpcClient(client: IAcpPermissionService | undefined): void { + AcpPermissionCallerService.staticRpcClient = client; } - removeConnectionClientId(clientId: string): void { - if (this.clientId === clientId) { - if (AcpPermissionCallerManager.currentRpcClient === this.client) { - AcpPermissionCallerManager.currentRpcClient = null; - } - this.clientId = undefined; - } + /** + * Get the RPC client from the shared static set by + * bindModuleBackService on the child-injector instance. + */ + private getRpcClient(): IAcpPermissionService | undefined { + return this.client ?? AcpPermissionCallerService.staticRpcClient; } /** - * Request permission from the user via browser dialog + * Request permission from the user via browser dialog. + * + * @param params - The SDK RequestPermissionRequest from the agent. + * @param sessionId - The session that owns this request. + * @returns RequestPermissionResponse with the user's decision. */ - async requestPermission(request: RequestPermissionRequest): Promise { + async requestPermission(params: RequestPermissionRequest, sessionId: string): Promise { // Check environment variable to skip permission confirmation - // Set SKIP_PERMISSION_CHECK=true to always allow without dialog - const skipPermissionCheck = process.env.SKIP_PERMISSION_CHECK === 'true'; - - if (skipPermissionCheck) { - const allowOptionId = this.findAllowOptionId(request.options); + if (process.env.SKIP_PERMISSION_CHECK === 'true') { + const allowOptionId = this.findAllowOptionId(params.options); return { outcome: { outcome: 'selected' as const, @@ -73,64 +76,59 @@ export class AcpPermissionCallerManager extends RPCService ({ + requestId: `${sessionId}:${params.toolCall.toolCallId}`, + sessionId, + title: params.toolCall.title ?? 'Permission Request', + kind: params.toolCall.kind ?? undefined, + content: this.buildPermissionContent(params), + locations: params.toolCall.locations?.map((loc) => ({ path: loc.path, line: loc.line ?? undefined, })), - options: this.sortOptionsByKind(request.options), + options: this.sortOptionsByKind(params.options), timeout: 60000, }; const decision = await rpcClient.$showPermissionDialog(dialogParams); - return this.buildPermissionResponse(decision, request.options); + return this.buildPermissionResponse(decision, params.options); + } + + /** + * Cancel a pending permission request + */ + async cancelRequest(requestId: string): Promise { + try { + const rpcClient = this.getRpcClient(); + if (rpcClient) { + await rpcClient.$cancelRequest(requestId); + } + } catch { + // Silently ignore cancellation errors + } } /** * Find the first "allow" option from the options list */ private findAllowOptionId(options: PermissionOption[]): string { - // 优先返回 allow_once const allowOnce = options.find((o) => o.kind === 'allow_once'); if (allowOnce) { return allowOnce.optionId; } - // 其次返回 allow_always const allowAlways = options.find((o) => o.kind === 'allow_always'); if (allowAlways) { return allowAlways.optionId; } - // 兜底返回第一个选项 return options[0]?.optionId || ''; } - /** - * 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[] = []; @@ -158,7 +156,7 @@ export class AcpPermissionCallerManager extends RPCService allow_once > reject_always > reject_once */ private sortOptionsByKind(options: PermissionOption[]): PermissionOption[] { @@ -220,3 +218,13 @@ export class AcpPermissionCallerManager extends RPCService; +} { + // 1. nodePath: env var escape hatch > preference > process.execPath + const nodePath = input.processEnv.SUMI_ACP_NODE_PATH || input.config.nodePath || input.processExecPath; + + // 1a. Absolute path validation (fail-fast) + if (!path.isAbsolute(nodePath)) { + throw new Error( + `nodePath must be an absolute path, got: "${nodePath}". ` + + 'Set ai-native.acp.nodePath or SUMI_ACP_NODE_PATH to an absolute path.', + ); + } + + const nodeBinDir = path.dirname(nodePath); + + // 2. command: env var escape hatch > browser-resolved value + const command = input.processEnv.SUMI_ACP_AGENT_PATH || input.config.command; + + // 3. Final env: process + merged env + forced NODE/PATH + const envFromConfig: Record = {}; + for (const v of input.config.env ?? []) {envFromConfig[v.name] = v.value;} + + const env: Record = { + ...input.processEnv, + ...envFromConfig, + NODE: path.join(nodeBinDir, 'node'), + PATH: `${nodeBinDir}${path.delimiter}${input.processEnv.PATH ?? ''}`, + }; + + return { command, args: input.config.args, env }; +} diff --git a/packages/ai-native/src/node/acp/acp-thread-status-caller.service.ts b/packages/ai-native/src/node/acp/acp-thread-status-caller.service.ts new file mode 100644 index 0000000000..a585937e27 --- /dev/null +++ b/packages/ai-native/src/node/acp/acp-thread-status-caller.service.ts @@ -0,0 +1,34 @@ +import { Injectable } from '@opensumi/di'; +import { RPCService } from '@opensumi/ide-connection'; + +import type { IAcpThreadStatusService } from '@opensumi/ide-core-common/lib/types/ai-native/acp-types'; + +export const AcpThreadStatusCallerServiceToken = Symbol('AcpThreadStatusCallerServiceToken'); + +/** + * Node-side service that pushes thread status changes to the browser via RPC. + * + * Uses the same staticRpcClient pattern as AcpPermissionCallerService + * to bridge parent/child injector scopes. + */ +@Injectable() +export class AcpThreadStatusCallerService extends RPCService { + static staticRpcClient: IAcpThreadStatusService | undefined; + + static setStaticRpcClient(client: IAcpThreadStatusService | undefined): void { + AcpThreadStatusCallerService.staticRpcClient = client; + } + + private getRpcClient(): IAcpThreadStatusService | undefined { + return this.client ?? AcpThreadStatusCallerService.staticRpcClient; + } + + notifyThreadStatusChange(sessionId: string, status: string): void { + const rpcClient = this.getRpcClient(); + if (rpcClient) { + rpcClient.$onThreadStatusChange(sessionId, status).catch(() => { + // Silently ignore — browser may not be ready + }); + } + } +} diff --git a/packages/ai-native/src/node/acp/acp-thread.ts b/packages/ai-native/src/node/acp/acp-thread.ts new file mode 100644 index 0000000000..aca95ce7d5 --- /dev/null +++ b/packages/ai-native/src/node/acp/acp-thread.ts @@ -0,0 +1,1495 @@ +/** + * AcpThread — core Thread AI entity. + * + * Encapsulates: + * 1. Agent process lifecycle (spawn / kill via child_process.spawn) + * 2. SDK ClientSideConnection (via dynamic ESM import for Node 16 compat) + * 3. Entries state management (ordered list of AgentThreadEntry) + * 4. Client interface implementation for the SDK + * 5. Event system via Emitter + * + * NOT decorated with @Injectable() — manually instantiated by AcpThreadFactory. + */ + +import { ChildProcess, spawn } from 'node:child_process'; +import { EventEmitter as NodeEventEmitter } from 'node:events'; +import * as streamWeb from 'node:stream/web'; + +import { Autowired, Injectable, Injector, Provider } from '@opensumi/di'; +import { Deferred, Disposable, Emitter, Event, ILogger, URI, uuid } from '@opensumi/ide-core-common'; +import { + AgentCapabilities, + CancelNotification, + CloseSessionRequest, + CloseSessionResponse, + ContentBlock, + EnvVariable, + ForkSessionRequest, + ForkSessionResponse, + InitializeRequest, + InitializeResponse, + ListSessionsRequest, + ListSessionsResponse, + LoadSessionRequest, + LoadSessionResponse, + NewSessionRequest, + NewSessionResponse, + PermissionOption, + PermissionOptionKind, + Plan, + PromptRequest, + PromptResponse, + ReadTextFileRequest, + ReadTextFileResponse, + RequestPermissionRequest, + RequestPermissionResponse, + ResumeSessionRequest, + ResumeSessionResponse, + SessionNotification, + SetSessionConfigOptionRequest, + SetSessionConfigOptionResponse, + SetSessionModeRequest, + SetSessionModeResponse, + SetSessionModelRequest, + SetSessionModelResponse, + ToolCall, + ToolCallUpdate, + WriteTextFileRequest, + WriteTextFileResponse, +} from '@opensumi/ide-core-common/lib/types/ai-native/acp-types'; +import { AcpWebMcpCallerServiceToken } from '@opensumi/ide-core-common/lib/types/ai-native/acp-types'; +import { AgentProcessConfig } from '@opensumi/ide-core-common/lib/types/ai-native/agent-types'; +import { INodeLogger } from '@opensumi/ide-core-node'; + +import { resolveAgentSpawnConfig } from './acp-spawn-config'; +import { AcpWebMcpHandler } from './acp-webmcp-handler'; +import { AcpFileSystemHandler, AcpFileSystemHandlerToken } from './handlers/file-system.handler'; +import { AcpTerminalHandler, AcpTerminalHandlerToken } from './handlers/terminal.handler'; +import { PermissionRoutingService, PermissionRoutingServiceToken } from './permission-routing.service'; + +import type { AgentUpdate, SimpleToolCall } from './acp-update-types'; +import type { AcpWebMcpCallerService } from './acp-webmcp-caller.service'; + +// --------------------------------------------------------------------------- +// Polyfill Web Streams for Node 16 +// --------------------------------------------------------------------------- +function ensureWebStreamPolyfill(): void { + if (typeof globalThis.ReadableStream === 'undefined' && streamWeb.ReadableStream) { + (globalThis as any).ReadableStream = streamWeb.ReadableStream; + } + if (typeof globalThis.WritableStream === 'undefined' && streamWeb.WritableStream) { + (globalThis as any).WritableStream = streamWeb.WritableStream; + } +} + +ensureWebStreamPolyfill(); + +// --------------------------------------------------------------------------- +// SDK dynamic import cache +// --------------------------------------------------------------------------- +let sdkModuleCache: any = null; + +async function loadSdk(): Promise { + if (!sdkModuleCache) { + sdkModuleCache = await import('@agentclientprotocol/sdk'); + } + return sdkModuleCache; +} + +// --------------------------------------------------------------------------- +// Node Stream → Web Stream conversion helpers +// --------------------------------------------------------------------------- +function nodeReadableToWebStream(readable: NodeJS.ReadableStream): ReadableStream { + return new streamWeb.ReadableStream({ + start(controller) { + readable.on('data', (chunk: Buffer) => { + controller.enqueue(new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength)); + }); + readable.on('end', () => { + controller.close(); + }); + readable.on('error', (err) => { + controller.error(err); + }); + }, + cancel() { + // no-op — we don't cancel the node stream from here + }, + }); +} + +function nodeWritableToWebStream(writable: NodeJS.WritableStream): WritableStream { + return new streamWeb.WritableStream({ + write(chunk) { + return new Promise((resolve, reject) => { + writable.write(chunk, (err) => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + }); + }, + close() { + // no-op — we let the caller manage lifecycle + }, + abort() { + // no-op + }, + }); +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- +const PROCESS_CONFIG = { + /** Graceful shutdown timeout (ms) */ + GRACEFUL_SHUTDOWN_TIMEOUT_MS: 5000, + /** Force kill timeout (ms) */ + FORCE_KILL_TIMEOUT_MS: 3000, + /** Startup timeout (ms) */ + STARTUP_TIMEOUT_MS: 100, +} as const; + +const ACP_PROTOCOL_VERSION = 1; + +// --------------------------------------------------------------------------- +// Thread status state machine +// --------------------------------------------------------------------------- +export type ThreadStatus = 'idle' | 'working' | 'awaiting_prompt' | 'auth_required' | 'errored' | 'disconnected'; + +// --------------------------------------------------------------------------- +// Tool call status state machine +// --------------------------------------------------------------------------- +export type ToolCallStatus = + | 'pending' + | 'in_progress' + | 'waiting_for_confirmation' + | 'completed' + | 'failed' + | 'rejected' + | 'canceled'; + +// --------------------------------------------------------------------------- +// Entry data types — use SDK types for content, add local tracking fields +// --------------------------------------------------------------------------- + +/** User message — simplified to string (SDK's PromptRequest.prompt is ContentBlock[]) */ +export interface UserMessageEntry { + id: string; + content: string; + timestamp: number; +} + +/** Assistant message — chunks use SDK ContentBlock[], local isComplete flag */ +export interface AssistantMessageEntry { + chunks: ContentBlock[]; + isComplete: boolean; + messageId?: string; +} + +/** Tool Call — toolCall uses SDK ToolCall type, local status + result */ +export interface ToolCallEntry { + toolCall: ToolCall; + status: ToolCallStatus; + result?: unknown; +} + +/** Plan — SDK type directly, no wrapper needed */ +// Plan = { entries: Array<{ content: string; completed: boolean }> } + +/** AgentThreadEntry — discriminated union with data wrapper pattern */ +export type AgentThreadEntry = + | { type: 'user_message'; data: UserMessageEntry } + | { type: 'assistant_message'; data: AssistantMessageEntry } + | { type: 'tool_call'; data: ToolCallEntry } + | { type: 'plan'; data: Plan }; + +// --------------------------------------------------------------------------- +// Event types — granular events (not bulk entries_changed) +// --------------------------------------------------------------------------- +export type AcpThreadEvent = + | { type: 'entry_added'; entry: AgentThreadEntry } + | { type: 'entry_updated'; entry: AgentThreadEntry } + | { type: 'status_changed'; status: ThreadStatus } + | { type: 'session_notification'; notification: SessionNotification } + | { type: 'error'; error: Error } + | { type: 'process_started' } + | { type: 'process_stopped' }; + +// --------------------------------------------------------------------------- +// DI Token and Interface +// --------------------------------------------------------------------------- +export const AcpThreadToken = Symbol('AcpThreadToken'); + +export interface IAcpThread { + /** Unique thread identifier */ + readonly threadId: string; + + /** Current session ID (bound after newSession/loadSession) */ + readonly sessionId: string; + + /** Current thread status */ + readonly status: ThreadStatus; + + /** Ordered list of thread entries */ + readonly entries: ReadonlyArray; + + /** Whether the thread has been initialized */ + readonly initialized: boolean; + + /** Whether the agent process is running */ + readonly isProcessRunning: boolean; + + /** Whether the SDK connection is established */ + readonly isConnected: boolean; + + /** Whether the thread was bound to a session and needs reset() before reuse */ + readonly needsReset: boolean; + + /** Agent capabilities from initialize */ + readonly agentCapabilities: AgentCapabilities | null; + + /** Event emitter for thread events */ + readonly onEvent: Event; + + // Process lifecycle + initialize(config: AgentProcessConfig): Promise; + newSession(params?: Omit): Promise; + loadSession(params: LoadSessionRequest): Promise; + loadSessionOrNew(params: LoadSessionRequest): Promise; + prompt(params: PromptRequest): Promise; + cancel(params: CancelNotification): Promise; + listSessions(params?: ListSessionsRequest): Promise; + + // Session mode & config + setSessionMode(params: SetSessionModeRequest): Promise; + setSessionConfigOption(params: SetSessionConfigOptionRequest): Promise; + + // Unstable session operations + unstable_forkSession(params: ForkSessionRequest): Promise; + unstable_resumeSession(params: ResumeSessionRequest): Promise; + unstable_closeSession(params: CloseSessionRequest): Promise; + unstable_setSessionModel(params: SetSessionModelRequest): Promise; + + // State management (internal + testing) + getEntries(): ReadonlyArray; + getStatus(): ThreadStatus; + setStatus(status: ThreadStatus): void; + setError(error: Error): void; + handleNotification(notification: SessionNotification): void; + + // Message manipulation + addUserMessage(content: string): UserMessageEntry; + markAssistantComplete(): void; + + // ToolCall interaction + markToolCallWaiting(toolCallId: string): void; + respondToToolCall(toolCallId: string, allowed: boolean): void; + + // Lifecycle + reset(): void; + dispose(): Promise; +} + +// --------------------------------------------------------------------------- +// Constructor options +// --------------------------------------------------------------------------- +export interface AcpThreadOptions { + agentId: string; + command: string; + args: string[]; + env?: EnvVariable[]; + cwd: string; + nodePath?: string; + fileSystemHandler: AcpFileSystemHandler; + terminalHandler: AcpTerminalHandler; + permissionRouting: PermissionRoutingService; + logger: INodeLogger; + webmcpCallerService?: AcpWebMcpCallerService; +} + +// --------------------------------------------------------------------------- +// Factory — DI factory for creating AcpThread instances +// --------------------------------------------------------------------------- + +/** + * Runtime configuration for creating an AcpThread. + * Provided by the caller (e.g., AcpAgentService) at thread creation time. + */ +export interface AcpThreadRuntimeConfig { + agentId: string; + command: string; + args: string[]; + env?: EnvVariable[]; + cwd: string; + nodePath?: string; +} + +/** + * Factory function type — creates an AcpThread for the given sessionId. + * Dependencies (fileSystemHandler, terminalHandler, permissionCaller, logger) + * are injected by the DI system. Runtime parameters (command, args, cwd, env) + * are provided by the caller. + */ +export type AcpThreadFactory = (sessionId: string, config: AcpThreadRuntimeConfig) => AcpThread; + +export const AcpThreadFactoryToken = Symbol('AcpThreadFactoryToken'); + +/** + * Provider definition for the AcpThreadFactory. + * Uses useFactory pattern with Injector to resolve dependencies. + * + * Usage in consumer: + * @Autowired(AcpThreadFactoryToken) + * private threadFactory: AcpThreadFactory; + * + * const thread = this.threadFactory(sessionId, { + * command: '/path/to/agent', + * args: ['--stdio'], + * cwd: workspaceDir, + * }); + */ +export const AcpThreadFactoryProvider: Provider = { + token: AcpThreadFactoryToken, + useFactory: (injector: Injector) => { + const fileSystemHandler = injector.get(AcpFileSystemHandlerToken); + const terminalHandler = injector.get(AcpTerminalHandlerToken); + const permissionRouting = injector.get(PermissionRoutingServiceToken); + const logger = injector.get(INodeLogger); + const webmcpCallerService = injector.get(AcpWebMcpCallerServiceToken) as AcpWebMcpCallerService; + + return (sessionId: string, config: AcpThreadRuntimeConfig) => + new AcpThread({ + agentId: config.agentId, + command: config.command, + args: config.args, + env: config.env, + cwd: config.cwd, + nodePath: config.nodePath, + fileSystemHandler, + terminalHandler, + permissionRouting, + logger, + webmcpCallerService, + }); + }, +}; + +// --------------------------------------------------------------------------- +// AcpThread Implementation +// --------------------------------------------------------------------------- +export class AcpThread extends Disposable implements IAcpThread { + readonly threadId: string = uuid(); + + /** Working directory of the thread's agent process */ + get cwd(): string { + return this.options.cwd; + } + + // State + private _status: ThreadStatus = 'idle'; + private _entries: AgentThreadEntry[] = []; + private _sessionId: string = ''; + private _needsReset = false; + private _agentCapabilities: AgentCapabilities | null = null; + private _initialized = false; + + // Process + private _childProcess: ChildProcess | null = null; + private _processRunning = false; + + // SDK + private _connection: any = null; // ClientSideConnection instance + private _connected = false; + + // WebMCP handler + private webmcpHandler: AcpWebMcpHandler | null = null; + + // Permission request tracking + private _pendingPermissionRequests = new Map< + string, + { resolve: (resp: RequestPermissionResponse) => void; reject: (err: Error) => void } + >(); + + // Event emitter + private _eventEmitter = new Emitter(); + + get onEvent(): Event { + return this._eventEmitter.event; + } + + get status(): ThreadStatus { + return this._status; + } + + get entries(): ReadonlyArray { + return this._entries; + } + + get initialized(): boolean { + return this._initialized; + } + + get isProcessRunning(): boolean { + return this._processRunning; + } + + get isConnected(): boolean { + return this._connected; + } + + get sessionId(): string { + return this._sessionId; + } + + get needsReset(): boolean { + return this._needsReset; + } + + get agentCapabilities(): AgentCapabilities | null { + return this._agentCapabilities; + } + + constructor(private readonly options: AcpThreadOptions) { + super(); + } + + // ----------------------------------------------------------------------- + // Public API — state accessors (spec) + // ----------------------------------------------------------------------- + getEntries(): ReadonlyArray { + return this._entries; + } + + getStatus(): ThreadStatus { + return this._status; + } + + setStatus(status: ThreadStatus): void { + if (this._status === status) { + return; + } + this.logger?.log(`[AcpThread:${this.threadId}] setStatus() — ${this._status} → ${status}`); + this._status = status; + this.fireEvent({ type: 'status_changed', status } as AcpThreadEvent); + } + + setError(error: Error): void { + this._status = 'errored'; + this.fireEvent({ type: 'status_changed', status: 'errored' } as AcpThreadEvent); + this.fireEvent({ type: 'error', error } as AcpThreadEvent); + } + + // ----------------------------------------------------------------------- + // Process lifecycle + // ----------------------------------------------------------------------- + private async startProcess(): Promise { + if (this._childProcess && this.isProcessAlive()) { + return; + } + + // Clean up stale process reference + this._childProcess = null; + this._processRunning = false; + + const resolved = resolveAgentSpawnConfig({ + config: { + agentId: this.options.agentId, + command: this.options.command, + args: this.options.args, + env: this.options.env, + cwd: this.options.cwd, + nodePath: this.options.nodePath, + }, + processEnv: process.env, + processExecPath: process.execPath, + }); + + return new Promise((resolve, reject) => { + let startupError: Error | null = null; + + const childProcess = spawn(resolved.command, resolved.args, { + cwd: this.options.cwd, + stdio: ['pipe', 'pipe', 'pipe'], + detached: false, + shell: false, + env: resolved.env, + }); + + childProcess.on('error', (err: Error) => { + startupError = err; + this.logger?.error(`[AcpThread:${this.threadId}] Failed to start process: ${err.message}`); + reject(this.wrapError(err, this.options.command)); + }); + + childProcess.stderr?.on('data', (data: Buffer) => { + this.logger?.warn(`[AcpThread:${this.threadId}] Agent stderr:`, data.toString('utf8')); + }); + + childProcess.on('exit', (code: number | null, signal: string | null) => { + this.logger?.log(`[AcpThread:${this.threadId}] Process exited: code=${code}, signal=${signal}`); + this._processRunning = false; + this._connected = false; + this.setStatus('disconnected'); + this.fireEvent({ type: 'process_stopped' } as AcpThreadEvent); + }); + + setTimeout(() => { + if (startupError) { + return; + } + if (!childProcess.pid) { + reject(new Error(`Failed to get PID for agent process: ${this.options.command}`)); + return; + } + this._childProcess = childProcess; + this._processRunning = true; + this.fireEvent({ type: 'process_started' } as AcpThreadEvent); + resolve(); + }, PROCESS_CONFIG.STARTUP_TIMEOUT_MS); + }); + } + + private isProcessAlive(): boolean { + if (!this._childProcess) { + return false; + } + if (this._childProcess.killed || this._childProcess.exitCode !== null) { + return false; + } + if (!this._childProcess.pid) { + return false; + } + try { + process.kill(this._childProcess.pid, 0); + return true; + } catch { + return false; + } + } + + private async killProcess(): Promise { + if (!this._childProcess || !this._childProcess.pid) { + this._childProcess = null; + this._processRunning = false; + return; + } + + const pid = this._childProcess.pid; + (this._childProcess as any).killed = true; + + // Try SIGTERM first + try { + process.kill(-pid, 'SIGTERM'); + } catch { + try { + process.kill(pid, 'SIGTERM'); + } catch { + // process already dead + } + } + + return new Promise((resolve) => { + const timeout = setTimeout(() => { + // Force kill + try { + process.kill(-pid, 'SIGKILL'); + } catch { + try { + process.kill(pid, 'SIGKILL'); + } catch { + // ignore + } + } + this._childProcess = null; + this._processRunning = false; + resolve(); + }, PROCESS_CONFIG.GRACEFUL_SHUTDOWN_TIMEOUT_MS); + + this._childProcess!.once('exit', () => { + clearTimeout(timeout); + this._childProcess = null; + this._processRunning = false; + resolve(); + }); + }); + } + + // ----------------------------------------------------------------------- + // SDK connection + // ----------------------------------------------------------------------- + private async ensureSdkConnection(): Promise { + if (this._connection) { + return; + } + + await this.startProcess(); + + const sdk = await loadSdk(); + const { ClientSideConnection, ndJsonStream } = sdk; + + const stdout = this._childProcess!.stdio[1] as NodeJS.ReadableStream; + const stdin = this._childProcess!.stdio[0] as NodeJS.WritableStream; + + const webOutputStream = nodeWritableToWebStream(stdin); + const webInputStream = nodeReadableToWebStream(stdout); + + const stream = ndJsonStream(webOutputStream, webInputStream); + + const clientImpl = this.createClientImpl(); + this._connection = new ClientSideConnection((_agent: any) => clientImpl, stream); + + this._connected = true; + + // Initialize WebMCP handler if caller service is available + // Handler uses lazy initialization — group definitions are fetched on first _opensumi/* call + const webmcpCaller = this.options.webmcpCallerService; + if (webmcpCaller) { + this.webmcpHandler = new AcpWebMcpHandler(webmcpCaller, this.logger); + } + } + + private createClientImpl(): any { + const self = this; + + return { + async requestPermission(params: RequestPermissionRequest): Promise { + return self.handlePermissionRequest(params); + }, + + async sessionUpdate(params: SessionNotification): Promise { + self.handleNotification(params); + self.fireEvent({ + type: 'session_notification', + notification: params, + } as AcpThreadEvent); + }, + + async readTextFile(params: ReadTextFileRequest): Promise { + const result = await self.options.fileSystemHandler.readTextFile({ + sessionId: params.sessionId, + path: params.path, + line: params.line ?? undefined, + limit: params.limit ?? undefined, + }); + return result as unknown as ReadTextFileResponse; + }, + + async writeTextFile(params: WriteTextFileRequest): Promise { + const result = await self.options.fileSystemHandler.writeTextFile({ + sessionId: params.sessionId, + path: params.path, + content: params.content, + }); + return result as unknown as WriteTextFileResponse; + }, + + async createTerminal(params: any): Promise { + const result = await self.options.terminalHandler.createTerminal({ + sessionId: params.sessionId, + command: params.command, + args: params.args, + env: params.env, + cwd: params.cwd, + }); + if (result.error) { + throw new Error(result.error.message); + } + return { terminalId: result.terminalId! }; + }, + + async terminalOutput(params: any): Promise { + const result = await self.options.terminalHandler.getTerminalOutput(params.terminalId, params.sessionId); + if (result.error) { + throw new Error(result.error.message); + } + return { + output: result.output || '', + truncated: result.truncated || false, + exitStatus: result.exitStatus ?? null, + }; + }, + + async waitForTerminalExit(params: any): Promise { + const result = await self.options.terminalHandler.waitForTerminalExit(params.terminalId, params.sessionId); + if (result.error) { + throw new Error(result.error.message); + } + return { + exitCode: result.exitCode ?? null, + }; + }, + + async killTerminal(params: any): Promise { + const result = await self.options.terminalHandler.killTerminal(params.terminalId, params.sessionId); + if (result.error) { + throw new Error(result.error.message); + } + return {}; + }, + + async releaseTerminal(params: any): Promise { + const result = await self.options.terminalHandler.releaseTerminal(params.terminalId, params.sessionId); + if (result.error) { + throw new Error(result.error.message); + } + }, + + async extMethod(method: string, params: Record): Promise> { + self.logger?.log( + `[AcpThread:${self.threadId}] extMethod() — method=${method}, params=${JSON.stringify(params)}`, + ); + if (method.startsWith('_opensumi/')) { + if (self.webmcpHandler) { + const result = await self.webmcpHandler.handleExtMethod(method, params); + self.logger?.log( + `[AcpThread:${self.threadId}] extMethod() — method=${method}, result=${JSON.stringify(result)}`, + ); + return result; + } + self.logger?.warn( + `[AcpThread:${self.threadId}] extMethod() — method=${method}, WebMCP handler not available`, + ); + throw Object.assign(new Error(`Method not found: ${method} (WebMCP not available)`), { code: -32601 }); + } + self.logger?.warn(`[AcpThread:${self.threadId}] extMethod() — method=${method} not implemented`); + throw Object.assign(new Error(`Method not found: ${method}`), { code: -32601 }); + }, + + async extNotification(method: string, params: Record): Promise { + self.logger?.log( + `[AcpThread:${self.threadId}] extNotification() — method=${method}, params=${JSON.stringify(params)}`, + ); + if (method.startsWith('_opensumi/') && self.webmcpHandler) { + self.webmcpHandler.handleExtNotification(method, params); + return; + } + self.logger?.debug(`[AcpThread:${self.threadId}] extNotification: ${method} — unhandled`, params); + }, + }; + } + + // ----------------------------------------------------------------------- + // Public API — initialize (spec: accepts AgentProcessConfig) + // ----------------------------------------------------------------------- + async initialize(config: AgentProcessConfig): Promise { + this.logger?.log( + `[AcpThread:${this.threadId}] initialize() — agent=${config.command || this.options.command}, cwd=${config.cwd}`, + ); + await this.ensureSdkConnection(); + + // Eagerly initialize WebMCP handler so group definitions are available + // for the capability metadata sent in initParams. + if (this.webmcpHandler) { + await this.webmcpHandler.ensureInitialized(); + } + + const initParams: InitializeRequest = { + protocolVersion: ACP_PROTOCOL_VERSION, + clientCapabilities: { + fs: { + readTextFile: true, + writeTextFile: true, + }, + terminal: true, + _meta: this.webmcpHandler?.getCapabilityMeta() ?? {}, + }, + clientInfo: { + name: 'opensumi', + title: 'OpenSumi IDE', + version: '3.0.0', + }, + }; + + // Override with config if provided + if (config.env) { + initParams.clientCapabilities = { + ...initParams.clientCapabilities, + ...((config as any).clientCapabilities || {}), + }; + } + + this.logger?.log( + `[AcpThread:${this.threadId}] initialize() — initParams.clientCapabilities._meta=${JSON.stringify( + initParams.clientCapabilities?._meta ?? {}, + )}`, + ); + + const response: InitializeResponse = await this._connection.initialize(initParams); + + if (response.protocolVersion !== initParams.protocolVersion) { + if (response.protocolVersion > ACP_PROTOCOL_VERSION) { + throw new Error( + `Unsupported protocol version: ${response.protocolVersion}. ` + + `This client supports up to version ${ACP_PROTOCOL_VERSION}.`, + ); + } + } + + if (response.agentCapabilities) { + this._agentCapabilities = response.agentCapabilities; + } + + this._initialized = true; + this.logger?.log( + `[AcpThread:${this.threadId}] initialize() — done, protocolVersion=${ + response.protocolVersion + }, capabilities=${JSON.stringify(response.agentCapabilities)}`, + ); + return response; + } + + // ----------------------------------------------------------------------- + // Public API — session management + // ----------------------------------------------------------------------- + async newSession(params?: Omit): Promise { + await this.ensureInitialized(); + this.logger?.log( + `[AcpThread:${this.threadId}] newSession() — cwd=${params?.cwd ?? this.options.cwd}, mcpServers=${ + params?.mcpServers?.length ?? 0 + }`, + ); + + const request: NewSessionRequest = { + cwd: params?.cwd ?? this.options.cwd, + mcpServers: params?.mcpServers ?? [], + ...(params?._meta ? { _meta: params._meta } : {}), + }; + + const response: NewSessionResponse = await this._connection.newSession(request); + this._sessionId = response.sessionId; + this._needsReset = true; + this.setStatus('awaiting_prompt'); + this.logger?.log( + `[AcpThread:${this.threadId}] newSession() — sessionId=${response.sessionId}, status=awaiting_prompt`, + ); + return response; + } + + async loadSession(params: LoadSessionRequest): Promise { + await this.ensureInitialized(); + this.logger?.log(`[AcpThread:${this.threadId}] loadSession() — sessionId=${params.sessionId}`); + + const response: LoadSessionResponse = await this._connection.loadSession(params); + this._sessionId = params.sessionId; + this._needsReset = true; + this.setStatus('awaiting_prompt'); + this.logger?.log( + `[AcpThread:${this.threadId}] loadSession() — loaded sessionId=${params.sessionId}, status=awaiting_prompt`, + ); + return response; + } + + async loadSessionOrNew(params: LoadSessionRequest): Promise { + await this.ensureInitialized(); + this.logger?.log(`[AcpThread:${this.threadId}] loadSessionOrNew() — sessionId=${params.sessionId}`); + + // Try loading first; fall back to new session + try { + return await this.loadSession(params); + } catch { + // Session doesn't exist, create a new one with same cwd/mcpServers + this.logger?.log( + `[AcpThread:${this.threadId}] loadSessionOrNew() — session not found, falling back to newSession`, + ); + return await this.newSession({ + cwd: params.cwd ?? this.options.cwd, + mcpServers: params.mcpServers ?? [], + }); + } + } + + async prompt(params: PromptRequest): Promise { + await this.ensureInitialized(); + this.logger?.log(`[AcpThread:${this.threadId}] prompt() — status→working`); + this.setStatus('working'); + + const response: PromptResponse = await this._connection.prompt(params); + + // After prompt completes, transition to awaiting_prompt + if (this._status === 'working') { + this.setStatus('awaiting_prompt'); + this.logger?.log( + `[AcpThread:${this.threadId}] prompt() — done, status→awaiting_prompt, entries=${this._entries.length}`, + ); + } + return response; + } + + async cancel(params: CancelNotification): Promise { + this.logger?.log(`[AcpThread:${this.threadId}] cancel() — sessionId=${params.sessionId}`); + await this.ensureInitialized(); + await this._connection.cancel(params); + this.logger?.log(`[AcpThread:${this.threadId}] cancel() — done`); + } + + async listSessions(params?: ListSessionsRequest): Promise { + this.logger?.log(`[AcpThread:${this.threadId}] listSessions()`); + await this.ensureInitialized(); + return this._connection.listSessions(params || {}); + } + + async setSessionMode(params: SetSessionModeRequest): Promise { + this.logger?.log(`[AcpThread:${this.threadId}] setSessionMode() — modeId=${params.modeId}`); + await this.ensureInitialized(); + return this._connection.setSessionMode(params); + } + + async setSessionConfigOption(params: SetSessionConfigOptionRequest): Promise { + this.logger?.log(`[AcpThread:${this.threadId}] setSessionConfigOption()`); + await this.ensureInitialized(); + return this._connection.setSessionConfigOption(params); + } + + async unstable_forkSession(params: ForkSessionRequest): Promise { + this.logger?.log(`[AcpThread:${this.threadId}] unstable_forkSession()`); + await this.ensureInitialized(); + return this._connection.unstable_forkSession(params); + } + + async unstable_resumeSession(params: ResumeSessionRequest): Promise { + this.logger?.log(`[AcpThread:${this.threadId}] unstable_resumeSession()`); + await this.ensureInitialized(); + return this._connection.unstable_resumeSession(params); + } + + async unstable_closeSession(params: CloseSessionRequest): Promise { + this.logger?.log(`[AcpThread:${this.threadId}] unstable_closeSession()`); + await this.ensureInitialized(); + return this._connection.unstable_closeSession(params); + } + + async unstable_setSessionModel(params: SetSessionModelRequest): Promise { + this.logger?.log(`[AcpThread:${this.threadId}] unstable_setSessionModel()`); + await this.ensureInitialized(); + return this._connection.unstable_setSessionModel(params); + } + + // ----------------------------------------------------------------------- + // Entry manipulation + // ----------------------------------------------------------------------- + addUserMessage(content: string): UserMessageEntry { + this.logger?.log( + `[AcpThread:${this.threadId}] addUserMessage() — content length=${content.length}, entries=${this._entries.length}`, + ); + const entry: UserMessageEntry = { + id: uuid(), + content, + timestamp: Date.now(), + }; + const threadEntry: AgentThreadEntry = { type: 'user_message', data: entry }; + this._entries.push(threadEntry); + this.fireEntryAdded(threadEntry); + return entry; + } + + /** + * Mark the last assistant entry as complete. + * No parameters — finds the last assistant entry automatically. + * Transitions status to awaiting_prompt. + * Fires entry_updated + status_changed. + */ + markAssistantComplete(): void { + // Find last assistant_message entry + for (let i = this._entries.length - 1; i >= 0; i--) { + const e = this._entries[i]; + if (e.type === 'assistant_message') { + e.data.isComplete = true; + this.fireEntryUpdated(e); + if (this._status !== 'awaiting_prompt') { + this.setStatus('awaiting_prompt'); + } + return; + } + } + } + + // ----------------------------------------------------------------------- + // Tool call state management + // ----------------------------------------------------------------------- + markToolCallWaiting(toolCallId: string): void { + const entry = this._entries.find( + (e): e is Extract => + e.type === 'tool_call' && e.data.toolCall.toolCallId === toolCallId, + ); + if (entry) { + entry.data.status = 'waiting_for_confirmation'; + this.fireEntryUpdated(entry); + } + } + + /** + * Respond to a tool call permission request. + * Updates the ToolCallEntry.status to 'completed' if allowed, 'rejected' if not. + * Fires entry_updated. + */ + respondToToolCall(toolCallId: string, allowed: boolean): void { + const entry = this._entries.find( + (e): e is Extract => + e.type === 'tool_call' && e.data.toolCall.toolCallId === toolCallId, + ); + if (entry) { + entry.data.status = allowed ? 'completed' : 'rejected'; + this.fireEntryUpdated(entry); + } + } + + // ----------------------------------------------------------------------- + // Reset and dispose + // ----------------------------------------------------------------------- + /** + * Lightweight reset for pool reuse. + * Clears entries, status → idle, releases terminal mapping. + * Does NOT clear _initialized — thread remains reusable. + */ + reset(): void { + this.logger?.log( + `[AcpThread:${this.threadId}] reset() — clearing ${this._entries.length} entries, sessionId=${this._sessionId}, ${ + this._needsReset ? 'needsReset' : '' + }`, + ); + this._entries = []; + this._sessionId = ''; + this._needsReset = false; + // NOTE: Do NOT clear _initialized — thread remains initialized and reusable + this._pendingPermissionRequests.clear(); + this.setStatus('idle'); + } + + async dispose(): Promise { + this.logger?.log( + `[AcpThread:${this.threadId}] dispose() — status=${this._status}, entries=${this._entries.length}`, + ); + this._eventEmitter.dispose(); + await this.killProcess(); + this._connection = null; + this._connected = false; + this._pendingPermissionRequests.clear(); + super.dispose(); + } + + // ----------------------------------------------------------------------- + // Public — notification handling (spec: must be public) + // ----------------------------------------------------------------------- + handleNotification(params: SessionNotification): void { + const update = params.update; + if (!update) { + return; + } + + // this.logger?.log(`[AcpThread:${this.threadId}] handleNotification() — ${update.sessionUpdate}`); + + switch (update.sessionUpdate) { + case 'user_message_chunk': { + this.mergeUserMessageChunk(update); + break; + } + case 'agent_message_chunk': + case 'agent_thought_chunk': { + this.mergeAssistantMessageChunk(update); + break; + } + case 'tool_call': { + this.createToolCallEntry(update as any); + break; + } + case 'tool_call_update': { + this.updateToolCallEntry(update as ToolCallUpdate & { sessionUpdate: 'tool_call_update' }); + break; + } + case 'available_commands_update': { + // No entry change needed, just emit event (already done by sessionUpdate) + break; + } + case 'plan': { + this.updatePlanEntry(update); + break; + } + default: + this.logger?.debug(`[AcpThread:${this.threadId}] Unknown session update: ${update.sessionUpdate}`); + } + } + + // ----------------------------------------------------------------------- + // Notification → AgentUpdate translation + // ----------------------------------------------------------------------- + + /** + * Translate a SessionNotification into the legacy AgentUpdate format + * for stream consumption by AcpAgentService. + */ + toAgentUpdate(notification: SessionNotification): AgentUpdate | null { + const update = (notification as any).update; + if (!update) { + return null; + } + + switch (update.sessionUpdate) { + case 'agent_thought_chunk': { + const content = update.content; + if (content?.type === 'text') { + return { type: 'thought', content: content.text }; + } + return null; + } + + case 'agent_message_chunk': { + const content = update.content; + if (content?.type === 'text') { + return { type: 'message', content: content.text }; + } + return null; + } + + case 'tool_call': { + return { + type: 'tool_call', + content: update.title || update.toolCallId || '', + toolCall: { + toolCallId: update.toolCallId || '', + name: update.title || update.toolCallId || '', + input: (update.rawInput as Record) || {}, + status: 'pending' as const, + }, + }; + } + + case 'tool_call_update': { + if (update.status === 'completed' || update.status === 'failed') { + if (update.rawOutput != null) { + const outputText = + typeof update.rawOutput === 'string' ? update.rawOutput : JSON.stringify(update.rawOutput); + return { + type: 'tool_result', + content: outputText.slice(0, 2000), + toolCall: { + toolCallId: update.toolCallId || '', + name: '', + input: {}, + status: update.status as 'completed' | 'failed', + }, + }; + } + return null; + } + if (update.status === 'in_progress') { + return { + type: 'tool_call_status', + content: update.title || '', + toolCall: { + toolCallId: update.toolCallId || '', + name: update.title || '', + input: {}, + status: 'in_progress' as const, + }, + }; + } + // Emit diff content if present + if (update.content) { + for (const item of update.content) { + if (item.type === 'diff') { + return { + type: 'tool_result', + content: `Modified ${item.path}`, + }; + } + } + } + return null; + } + + case 'plan': { + const plan = update.plan; + if (plan?.entries?.length) { + const planText = plan.entries + .map((e: { content: string; completed?: boolean; status?: string }) => + e.completed ? `- [x] ${e.content}` : `- [ ] ${e.content}`, + ) + .join('\n'); + return { type: 'plan', content: planText }; + } + return null; + } + + default: + return null; + } + } + + private mergeUserMessageChunk(update: any): void { + const content = this.extractTextContent(update.content); + if (!content) { + return; + } + + // Try to merge into last user message (user messages may arrive in chunks) + const lastEntry = this._entries[this._entries.length - 1]; + if (lastEntry && lastEntry.type === 'user_message') { + (lastEntry.data as UserMessageEntry).content += content; + this.fireEntryUpdated(lastEntry); + } else { + // Create new entry + const entry: UserMessageEntry = { + id: uuid(), + content, + timestamp: Date.now(), + }; + const threadEntry: AgentThreadEntry = { type: 'user_message', data: entry }; + this._entries.push(threadEntry); + this.fireEntryAdded(threadEntry); + } + } + + private mergeAssistantMessageChunk(update: any): void { + const content = this.extractTextContent(update.content); + const thought = + update.sessionUpdate === 'agent_thought_chunk' ? this.extractTextContent(update.content) : undefined; + + // Find last incomplete assistant message + let lastAssistant: AssistantMessageEntry | undefined; + for (let i = this._entries.length - 1; i >= 0; i--) { + const e = this._entries[i]; + if (e.type === 'assistant_message' && !e.data.isComplete) { + lastAssistant = e.data; + break; + } + } + + if (lastAssistant) { + // Append to existing message + if (content) { + const existingTextBlock = lastAssistant.chunks.find( + (c): c is Extract => c.type === 'text', + ); + if (existingTextBlock) { + existingTextBlock.text += content; + } else { + lastAssistant.chunks.push({ type: 'text', text: content }); + } + } + if (thought) { + // Append thought as a separate text chunk or track separately + lastAssistant.chunks.push({ type: 'text', text: thought, _role: 'assistant' } as any); + } + // Find the thread entry to fire updated event + for (let i = this._entries.length - 1; i >= 0; i--) { + const e = this._entries[i]; + if (e.type === 'assistant_message' && e.data === lastAssistant) { + this.fireEntryUpdated(e); + break; + } + } + } else { + // Create new entry + const chunks: ContentBlock[] = []; + if (content) { + chunks.push({ type: 'text', text: content }); + } + if (thought) { + chunks.push({ type: 'text', text: thought, _role: 'assistant' } as any); + } + const entry: AssistantMessageEntry = { + chunks, + isComplete: false, + }; + const threadEntry: AgentThreadEntry = { type: 'assistant_message', data: entry }; + this._entries.push(threadEntry); + this.fireEntryAdded(threadEntry); + } + } + + private createToolCallEntry(update: any): void { + // Build SDK ToolCall from update + const toolCall: ToolCall = { + toolCallId: update.toolCallId, + title: update.toolName || update.title || update.toolCallId, + kind: update.kind, + rawInput: update.input, + status: 'pending', + }; + + const entry: ToolCallEntry = { + toolCall, + status: 'pending', + }; + const threadEntry: AgentThreadEntry = { type: 'tool_call', data: entry }; + this._entries.push(threadEntry); + this.fireEntryAdded(threadEntry); + + // Transition thread to working if idle + if (this._status === 'idle' || this._status === 'awaiting_prompt') { + this.setStatus('working'); + } + } + + private updateToolCallEntry(update: ToolCallUpdate & { sessionUpdate: 'tool_call_update' }): void { + // Find matching tool call entry by toolCallId + for (let i = this._entries.length - 1; i >= 0; i--) { + const e = this._entries[i]; + if (e.type === 'tool_call' && e.data.toolCall.toolCallId === update.toolCallId) { + const entry = e.data as ToolCallEntry; + + if (update.status === 'completed') { + entry.status = 'completed'; + entry.result = update.rawOutput; + // Also update the embedded ToolCall.status + entry.toolCall.status = 'completed'; + } else if (update.status === 'failed') { + entry.status = 'failed'; + entry.toolCall.status = 'failed'; + } else if (update.status === 'in_progress') { + if (entry.status === 'pending' || entry.status === 'waiting_for_confirmation') { + entry.status = 'in_progress'; + entry.toolCall.status = 'in_progress'; + } + } + + this.fireEntryUpdated(e); + break; + } + } + } + + private updatePlanEntry(update: any): void { + // Remove existing plan entries + this._entries = this._entries.filter((e) => e.type !== 'plan'); + + const plan = update.plan as Plan; + if (plan) { + const threadEntry: AgentThreadEntry = { type: 'plan', data: plan }; + this._entries.push(threadEntry); + this.fireEntryAdded(threadEntry); + } else { + // Fallback: extract from content field for backward compat + const content = this.extractTextContent(update.content); + if (content) { + const plan: Plan = { + entries: [{ content, status: 'pending', priority: 'medium' }], + }; + const threadEntry: AgentThreadEntry = { type: 'plan', data: plan }; + this._entries.push(threadEntry); + this.fireEntryAdded(threadEntry); + } + } + } + + private extractTextContent(contentBlock: any): string | undefined { + if (!contentBlock) { + return undefined; + } + if (typeof contentBlock === 'string') { + return contentBlock; + } + if (contentBlock.type === 'text') { + return contentBlock.text; + } + if (contentBlock.text) { + return contentBlock.text; + } + return undefined; + } + + // ----------------------------------------------------------------------- + // Internal — permission request handling + // ----------------------------------------------------------------------- + private async handlePermissionRequest(params: RequestPermissionRequest): Promise { + const sessionId = params.sessionId || this._sessionId; + const requestId = `${sessionId}:${params.toolCall.toolCallId}`; + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this._pendingPermissionRequests.delete(requestId); + resolve({ + outcome: { + outcome: 'cancelled', + }, + }); + }, 60000); // 60s timeout + + this._pendingPermissionRequests.set(requestId, { + resolve: (resp) => { + clearTimeout(timeout); + resolve(resp); + }, + reject: (err) => { + clearTimeout(timeout); + reject(err); + }, + }); + + // Forward to browser via permission caller + this.forwardPermissionRequest(params, requestId); + }); + } + + private async forwardPermissionRequest(params: RequestPermissionRequest, requestId: string): Promise { + try { + const sessionId = params.sessionId || this._sessionId; + const response = await this.options.permissionRouting.routePermissionRequest(params, sessionId); + // Resolve the pending request + const pending = this._pendingPermissionRequests.get(requestId); + if (pending) { + pending.resolve(response); + } + this.respondToToolCall(requestId, response.outcome.outcome !== 'cancelled'); + } catch (err) { + const pending = this._pendingPermissionRequests.get(requestId); + if (pending) { + pending.reject(err instanceof Error ? err : new Error(String(err))); + this._pendingPermissionRequests.delete(requestId); + } + } + } + + // ----------------------------------------------------------------------- + // Internal — helpers + // ----------------------------------------------------------------------- + private async ensureInitialized(): Promise { + if (!this._connection) { + throw new Error('AcpThread not initialized. Call initialize() first.'); + } + } + + private fireEntryAdded(entry: AgentThreadEntry): void { + this.fireEvent({ type: 'entry_added', entry } as AcpThreadEvent); + } + + private fireEntryUpdated(entry: AgentThreadEntry): void { + this.fireEvent({ type: 'entry_updated', entry } as AcpThreadEvent); + } + + private fireEvent(event: AcpThreadEvent): void { + if (this._eventEmitter) { + this._eventEmitter.fire(event); + } + } + + 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; + } + + // Logger passed via factory options (AcpThread is not @Injectable) + private get logger(): INodeLogger { + return this.options.logger; + } + + private get fileSystemHandler(): AcpFileSystemHandler { + return this.options.fileSystemHandler; + } + + private get terminalHandler(): AcpTerminalHandler { + return this.options.terminalHandler; + } + + private get permissionRouting(): PermissionRoutingService { + return this.options.permissionRouting; + } +} diff --git a/packages/ai-native/src/node/acp/acp-update-types.ts b/packages/ai-native/src/node/acp/acp-update-types.ts new file mode 100644 index 0000000000..05ea6baffe --- /dev/null +++ b/packages/ai-native/src/node/acp/acp-update-types.ts @@ -0,0 +1,30 @@ +/** + * Agent update types — shared format used by both AcpThread (translation) + * and AcpAgentService (stream consumption). + */ + +import type { ThreadStatus } from './acp-thread'; + +export type AgentUpdateType = + | 'thought' + | 'message' + | 'tool_call' + | 'tool_call_status' + | 'tool_result' + | 'plan' + | 'done' + | 'thread_status'; + +export interface SimpleToolCall { + toolCallId: string; + name: string; + input: Record; + status?: 'pending' | 'in_progress' | 'completed' | 'failed'; +} + +export interface AgentUpdate { + type: AgentUpdateType; + content: string; + toolCall?: SimpleToolCall; + threadStatus?: ThreadStatus; +} diff --git a/packages/ai-native/src/node/acp/acp-webmcp-caller.service.ts b/packages/ai-native/src/node/acp/acp-webmcp-caller.service.ts new file mode 100644 index 0000000000..551c602474 --- /dev/null +++ b/packages/ai-native/src/node/acp/acp-webmcp-caller.service.ts @@ -0,0 +1,46 @@ +import { Injectable } from '@opensumi/di'; +import { RPCService } from '@opensumi/ide-connection'; + +import type { + IAcpWebMcpBridgeService, + WebMcpGroupDef, + WebMcpToolResult, +} from '@opensumi/ide-core-common/lib/types/ai-native/acp-types'; + +/** + * Node-side RPC caller service for WebMCP bridge calls. + * Calls browser-side methods via RPC to retrieve group definitions and execute tools. + * + * Uses the same staticRpcClient pattern as AcpPermissionCallerService + * to bridge parent/child injector scopes: the child-injector instance + * (created by bindModuleBackService) gets this.client set, while + * parent-injector consumers need the static fallback. + */ +@Injectable() +export class AcpWebMcpCallerService extends RPCService { + static staticRpcClient: IAcpWebMcpBridgeService | undefined; + + static setStaticRpcClient(client: IAcpWebMcpBridgeService | undefined): void { + AcpWebMcpCallerService.staticRpcClient = client; + } + + private getRpcClient(): IAcpWebMcpBridgeService | undefined { + return this.client ?? AcpWebMcpCallerService.staticRpcClient; + } + + async getGroupDefinitions(): Promise { + const rpcClient = this.getRpcClient(); + if (!rpcClient) { + throw new Error('[AcpWebMcpCallerService] RPC client not available — browser connection not established'); + } + return rpcClient.$getGroupDefinitions(); + } + + async executeTool(group: string, tool: string, params: Record): Promise { + const rpcClient = this.getRpcClient(); + if (!rpcClient) { + throw new Error('[AcpWebMcpCallerService] RPC client not available — browser connection not established'); + } + return rpcClient.$executeTool(group, tool, params); + } +} diff --git a/packages/ai-native/src/node/acp/acp-webmcp-handler.ts b/packages/ai-native/src/node/acp/acp-webmcp-handler.ts new file mode 100644 index 0000000000..3822301728 --- /dev/null +++ b/packages/ai-native/src/node/acp/acp-webmcp-handler.ts @@ -0,0 +1,202 @@ +import type { AcpWebMcpCallerService } from './acp-webmcp-caller.service'; +import type { WebMcpGroupDef, WebMcpToolResult } from '@opensumi/ide-core-common/lib/types/ai-native/acp-types'; + +export class AcpWebMcpHandler { + private loadedGroups = new Set(); + private groupDefs: WebMcpGroupDef[] | null = null; + private totalLoadedToolCount = 0; + private initPromise: Promise | null = null; + + constructor( + private readonly caller: AcpWebMcpCallerService, + private readonly logger: { warn?: (...args: unknown[]) => void; debug?: (...args: unknown[]) => void } | undefined, + ) {} + + /** + * Lazily initialize group definitions from the browser-side registry. + * Safe to call multiple times — subsequent calls await the same promise. + */ + ensureInitialized(): Promise { + if (this.groupDefs !== null) { + return Promise.resolve(); + } + if (this.initPromise) { + return this.initPromise; + } + + this.initPromise = this.doInitialize(); + return this.initPromise; + } + + private async doInitialize(): Promise { + try { + this.groupDefs = await this.caller.getGroupDefinitions(); + // Auto-load default groups + for (const group of this.groupDefs) { + if (group.defaultLoaded) { + this.loadedGroups.add(group.name); + this.totalLoadedToolCount += group.tools.length; + } + } + this.logger?.debug?.( + `[AcpWebMcpHandler] Initialized — groups=${this.groupDefs.map((g) => g.name).join(',')}, ` + + `defaultLoaded=${[...this.loadedGroups].join(',')}, totalLoadedToolCount=${this.totalLoadedToolCount}`, + ); + } catch (err) { + this.logger?.warn?.('[AcpWebMcpHandler] Failed to initialize group definitions:', err); + this.groupDefs = []; + } + } + + async handleExtMethod(method: string, params: Record): Promise> { + await this.ensureInitialized(); + this.logger?.debug?.(`[AcpWebMcpHandler] handleExtMethod() — method=${method}, params=${JSON.stringify(params)}`); + + // Meta methods + if (method === '_opensumi/webmcp/list_groups') { + const result = this.listGroups(); + this.logger?.debug?.(`[AcpWebMcpHandler] list_groups() — groups count=${(result.groups as any[])?.length ?? 0}`); + return result; + } + if (method === '_opensumi/webmcp/load_group') { + const result = this.loadGroup(params); + this.logger?.debug?.( + `[AcpWebMcpHandler] load_group(${params.name}) — loaded=${!(result as any).error}, totalLoadedToolCount=${ + (result as any).totalLoadedToolCount + }`, + ); + return result; + } + if (method === '_opensumi/webmcp/unload_group') { + const result = this.unloadGroup(params); + this.logger?.debug?.( + `[AcpWebMcpHandler] unload_group(${params.name}) — unloadedMethods=${JSON.stringify( + (result as any).unloadedMethods, + )}, totalLoadedToolCount=${(result as any).totalLoadedToolCount}`, + ); + return result; + } + + // Group tool methods: _opensumi/{group}/{action} + if (method.startsWith('_opensumi/')) { + const result = await this.executeGroupTool(method, params); + this.logger?.debug?.( + `[AcpWebMcpHandler] executeGroupTool(${method}) — success=${(result as any).error ? false : true}`, + ); + return result; + } + + throw Object.assign(new Error(`Method not found: ${method}`), { code: -32601 }); + } + + handleExtNotification(method: string, _params: Record): void { + this.logger?.debug?.(`[AcpWebMcpHandler] extNotification: ${method}`); + } + + private listGroups(): Record { + const groups = (this.groupDefs ?? []).map((g) => ({ + name: g.name, + description: g.description, + defaultLoaded: g.defaultLoaded, + loaded: this.loadedGroups.has(g.name), + tools: g.tools.map((t) => ({ + method: t.method, + description: t.description, + inputSchema: t.inputSchema, + })), + })); + return { groups }; + } + + private loadGroup(params: Record): Record { + const name = params.name as string; + const group = (this.groupDefs ?? []).find((g) => g.name === name); + if (!group) { + return { error: 'GROUP_NOT_FOUND', details: `Group "${name}" not found` }; + } + const tools = group.tools.map((t) => ({ + method: t.method, + description: t.description, + inputSchema: t.inputSchema, + })); + if (this.loadedGroups.has(name)) { + return { + group: name, + tools, + totalLoadedToolCount: this.totalLoadedToolCount, + }; + } + this.loadedGroups.add(name); + this.totalLoadedToolCount += group.tools.length; + return { group: name, tools, totalLoadedToolCount: this.totalLoadedToolCount }; + } + + private unloadGroup(params: Record): Record { + const name = params.name as string; + const group = (this.groupDefs ?? []).find((g) => g.name === name); + if (!group) { + return { error: 'GROUP_NOT_FOUND', details: `Group "${name}" not found` }; + } + if (!this.loadedGroups.has(name)) { + return { group: name, unloadedMethods: [], totalLoadedToolCount: this.totalLoadedToolCount }; + } + this.loadedGroups.delete(name); + this.totalLoadedToolCount -= group.tools.length; + return { + group: name, + unloadedMethods: group.tools.map((t) => t.method), + totalLoadedToolCount: this.totalLoadedToolCount, + }; + } + + private async executeGroupTool(method: string, params: Record): Promise> { + // Parse _opensumi/{group}/{action} + // e.g. '_opensumi/file/read'.split('/') => ['_opensumi', 'file', 'read'] + const parts = method.split('/'); + if (parts.length !== 3 || parts[0] !== '_opensumi') { + return { success: false, error: 'TOOL_NOT_FOUND', details: `Invalid method: ${method}` }; + } + const groupName = parts[1]; + const toolAction = parts[2]; + + if (!this.loadedGroups.has(groupName)) { + this.logger?.warn?.( + `[AcpWebMcpHandler] executeGroupTool(${method}) — group "${groupName}" not loaded. Loaded groups: ${[ + ...this.loadedGroups, + ].join(',')}`, + ); + return { + success: false, + error: 'TOOL_NOT_LOADED', + details: `Group "${groupName}" is not loaded. Call _opensumi/webmcp/load_group first.`, + }; + } + + try { + this.logger?.debug?.( + `[AcpWebMcpHandler] executeGroupTool() — calling browser: group=${groupName}, action=${toolAction}`, + ); + const result = await this.caller.executeTool(groupName, toolAction, params); + this.logger?.debug?.( + `[AcpWebMcpHandler] executeGroupTool() — browser returned: group=${groupName}, action=${toolAction}, success=${result.success}`, + ); + return result as unknown as Record; + } catch (err) { + this.logger?.warn?.(`[AcpWebMcpHandler] executeGroupTool(${method}) — execution error:`, err); + return { success: false, error: 'EXECUTION_ERROR', details: String(err) }; + } + } + + getCapabilityMeta(): Record { + return { + opensumi: { + version: '1.0', + webmcp: { + methods: ['_opensumi/webmcp/list_groups', '_opensumi/webmcp/load_group', '_opensumi/webmcp/unload_group'], + groups: (this.groupDefs ?? []).map((g) => g.name), + defaultLoadedGroups: (this.groupDefs ?? []).filter((g) => g.defaultLoaded).map((g) => g.name), + }, + }, + }; + } +} 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 deleted file mode 100644 index 34cb853648..0000000000 --- a/packages/ai-native/src/node/acp/cli-agent-process-manager.ts +++ /dev/null @@ -1,446 +0,0 @@ -/** - * 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; - // 单独跟踪 command 和 cwd,因为 ChildProcess 没有这些属性 - private currentCommand: string | null = null; - 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; - } - } - - /** - * 比较配置是否相同(检查 command 和 cwd) - */ - private isConfigSame(command: string, args: string[], env: Record, cwd: string): boolean { - return command === this.currentCommand && 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}`); - // todo 避免多次创建,需要加一个创建中拦截 - // 检查是否已有进程且仍在运行 - 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.currentCommand = 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.currentCommand = command; - 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 { - // 从环境变量读取 Agent 命令路径,默认使用 command 参数 - // 通过设置 SUMI_ACP_AGENT_PATH 环境变量,可以指定 ACP Agent 的完整路径 - // 例如:export SUMI_ACP_AGENT_PATH=/usr/local/bin/claude-agent-acp - // 注意:如果设置了此环境变量,将覆盖 command 参数 - const agentPath = process.env.SUMI_ACP_AGENT_PATH || command; - const nodePath = process.env.SUMI_ACP_NODE_PATH || command; - const nodeBinDir = nodePath.substring(0, nodePath.lastIndexOf('/')); - this.logger?.log(`[CliAgentProcessManager] Using Agent path: ${agentPath}`); - this.logger?.log(`[CliAgentProcessManager] Spawning ACP Agent: ${agentPath} ${args.join(' ')}`); - this.logger?.log(`[CliAgentProcessManager] Spawning node path: ${nodePath} ${args.join(' ')}`); - - const newEnv = { - ...process.env, - ...env, - NODE: `${nodeBinDir}/node`, - PATH: `${nodeBinDir}:${process.env.PATH || ''}`, - }; - - const childProcess = spawn(agentPath, 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.currentCommand = 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.currentCommand = 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.currentCommand = 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 index 5c39f0c981..8e614e2dcb 100644 --- a/packages/ai-native/src/node/acp/handlers/agent-request.handler.ts +++ b/packages/ai-native/src/node/acp/handlers/agent-request.handler.ts @@ -31,8 +31,8 @@ import { } 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 { AcpPermissionCallerManagerToken, AcpPermissionCallerServiceToken } from '../../acp'; +import { AcpPermissionCallerService } from '../acp-permission-caller.service'; import { AcpFileSystemHandler, AcpFileSystemHandlerToken } from './file-system.handler'; import { AcpTerminalHandler, AcpTerminalHandlerToken } from './terminal.handler'; @@ -54,10 +54,10 @@ export const AcpAgentRequestHandlerToken = Symbol('AcpAgentRequestHandlerToken') * ### Injector 层级问题 * * 由于 `AcpAgentRequestHandler` 在主 Injector 中创建,它通过 `@Autowired` 注入的 - * `AcpPermissionCallerManager` 不是 childInjector 中与 RPC 连接关联的实例。 + * `AcpPermissionCallerService` 不是 childInjector 中与 RPC 连接关联的实例。 * - * 解决方案:`AcpPermissionCallerManager` 使用静态变量 `currentRpcClient` 共享 RPC client, - * 确保权限对话框在用户当前活跃的 Browser Tab 中显示。 + * 解决方案:`AcpPermissionCallerService` 使用 RPCService 框架自动注入的 `this.client` + * 来调用 Browser 端的 `AcpPermissionRpcService`,确保权限对话框在用户当前活跃的 Browser Tab 中显示。 * * @see {@link /docs/ai-native/architecture/injector-hierarchy.md} 详细设计文档 */ @@ -69,8 +69,8 @@ export class AcpAgentRequestHandler { @Autowired(AcpTerminalHandlerToken) private terminalHandler: AcpTerminalHandler; - @Autowired(AcpPermissionCallerManagerToken) - private permissionCaller: AcpPermissionCallerManager; + @Autowired(AcpPermissionCallerServiceToken) + private permissionCaller: AcpPermissionCallerService; @Autowired(INodeLogger) private readonly logger: INodeLogger; @@ -101,7 +101,7 @@ export class AcpAgentRequestHandler { async handlePermissionRequest(request: RequestPermissionRequest): Promise { try { // Call browser-side permission dialog via RPC - const response = await this.permissionCaller.requestPermission(request); + const response = await this.permissionCaller.requestPermission(request, request.sessionId); return response; } catch (error) { @@ -149,23 +149,26 @@ export class AcpAgentRequestHandler { 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 }, + 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' }, + ], }, - // 默认 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' }, - ], - }); + request.sessionId, + ); if ( permissionResponse.outcome.outcome !== 'selected' || @@ -204,22 +207,25 @@ export class AcpAgentRequestHandler { 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 }, + 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' }, + ], }, - // 默认 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' }, - ], - }); + request.sessionId, + ); if ( permissionResponse.outcome.outcome !== 'selected' || @@ -264,10 +270,7 @@ export class AcpAgentRequestHandler { */ async handleTerminalOutput(request: TerminalOutputRequest): Promise { try { - const result = await this.terminalHandler.getTerminalOutput({ - sessionId: request.sessionId, - terminalId: request.terminalId, - }); + const result = await this.terminalHandler.getTerminalOutput(request.terminalId, request.sessionId); if (result.error) { this.logger.error(`[ACP] Terminal output error: ${result.error.message}`); @@ -290,10 +293,7 @@ export class AcpAgentRequestHandler { */ async handleWaitForTerminalExit(request: WaitForTerminalExitRequest): Promise { try { - const result = await this.terminalHandler.waitForTerminalExit({ - sessionId: request.sessionId, - terminalId: request.terminalId, - }); + const result = await this.terminalHandler.waitForTerminalExit(request.terminalId, request.sessionId); if (result.error) { this.logger.error(`[ACP] Wait for exit error: ${result.error.message}`); @@ -315,10 +315,7 @@ export class AcpAgentRequestHandler { */ async handleKillTerminal(request: KillTerminalCommandRequest): Promise { try { - const result = await this.terminalHandler.killTerminal({ - sessionId: request.sessionId, - terminalId: request.terminalId, - }); + const result = await this.terminalHandler.killTerminal(request.terminalId, request.sessionId); if (result.error) { this.logger.error(`[ACP] Kill terminal error: ${result.error.message}`); @@ -337,10 +334,7 @@ export class AcpAgentRequestHandler { */ async handleReleaseTerminal(request: ReleaseTerminalRequest): Promise { try { - const result = await this.terminalHandler.releaseTerminal({ - sessionId: request.sessionId, - terminalId: request.terminalId, - }); + const result = await this.terminalHandler.releaseTerminal(request.terminalId, request.sessionId); if (result.error) { this.logger.error(`[ACP] Release terminal error: ${result.error.message}`); 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 index ec9101dfd8..dae7e8486b 100644 --- a/packages/ai-native/src/node/acp/handlers/file-system.handler.ts +++ b/packages/ai-native/src/node/acp/handlers/file-system.handler.ts @@ -3,10 +3,7 @@ * * 为 CLI Agent 提供受工作区沙箱限制的文件操作能力: * - readTextFile:读取文本文件内容,支持按行范围截取 - * - writeTextFile:写入文本文件,写入前可通过 permissionCallback 触发用户授权 - * - getFileMeta:获取文件元信息(大小、修改时间、MIME 类型等) - * - listDirectory:列举目录条目,支持一层递归 - * - createDirectory:创建目录(含父目录) + * - writeTextFile:写入文本文件 * * 安全机制:所有路径均经过 resolvePath 校验,拒绝工作区外的绝对路径和路径穿越攻击。 */ @@ -19,69 +16,49 @@ import { IFileService } from '@opensumi/ide-file-service'; import { ACPErrorCode } from './constants'; -export interface FileSystemRequest { +export const AcpFileSystemHandlerToken = Symbol('AcpFileSystemHandlerToken'); + +export interface ReadTextFileRequest { sessionId: string; path: string; line?: number; limit?: number; +} + +export interface ReadTextFileResponse { content?: string; - recursive?: boolean; + error?: { message: string; code: number }; } -export const AcpFileSystemHandlerToken = Symbol('AcpFileSystemHandlerToken'); +export interface WriteTextFileRequest { + sessionId: string; + path: string; + content: string; +} -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 interface WriteTextFileResponse { + error?: { message: string; code: 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; +export interface IAcpFileSystemHandler { + configure(options: { workspaceDir: string; maxFileSize?: number }): void; + readTextFile(req: ReadTextFileRequest): Promise; + writeTextFile(req: WriteTextFileRequest): Promise; +} @Injectable() -export class AcpFileSystemHandler { +export class AcpFileSystemHandler implements IAcpFileSystemHandler { @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) { @@ -89,14 +66,14 @@ export class AcpFileSystemHandler { } } - async readTextFile(request: FileSystemRequest): Promise { + async readTextFile(request: ReadTextFileRequest): Promise { + this.logger?.log(`[AcpFileSystemHandler] readTextFile() — sessionId=${request.sessionId}, path=${request.path}`); const filePath = this.resolvePath(request.path); if (!filePath) { return { error: { code: ACPErrorCode.SERVER_ERROR, message: 'Invalid path', - data: { path: request.path }, }, }; } @@ -111,7 +88,6 @@ export class AcpFileSystemHandler { error: { code: ACPErrorCode.RESOURCE_NOT_FOUND, message: 'File not found', - data: { uri: uri.toString() }, }, }; } @@ -122,7 +98,6 @@ export class AcpFileSystemHandler { error: { code: ACPErrorCode.SERVER_ERROR, message: `File too large: ${stat.size} bytes (max: ${this.maxFileSize})`, - data: { path: request.path, size: stat.size }, }, }; } @@ -148,55 +123,25 @@ export class AcpFileSystemHandler { error: { code: ACPErrorCode.SERVER_ERROR, message: error instanceof Error ? error.message : 'Failed to read file', - data: { path: request.path }, }, }; } } - async writeTextFile(request: FileSystemRequest): Promise { + async writeTextFile(request: WriteTextFileRequest): Promise { + this.logger?.log( + `[AcpFileSystemHandler] writeTextFile() — sessionId=${request.sessionId}, path=${request.path}, size=${request.content.length}`, + ); 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); @@ -225,176 +170,6 @@ export class AcpFileSystemHandler { 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 }, }, }; } diff --git a/packages/ai-native/src/node/acp/handlers/terminal.handler.ts b/packages/ai-native/src/node/acp/handlers/terminal.handler.ts index 283b18392e..dc687d1baf 100644 --- a/packages/ai-native/src/node/acp/handlers/terminal.handler.ts +++ b/packages/ai-native/src/node/acp/handlers/terminal.handler.ts @@ -2,8 +2,7 @@ * ACP 终端操作处理器 * * 为 CLI Agent 提供进程级终端(命令执行)能力: - * - createTerminal:创建新终端并执行命令,创建前可通过 permissionCallback 触发用户授权; - * 自动收集输出并按 outputByteLimit 滑动截断 + * - createTerminal:创建新终端并执行命令 * - getTerminalOutput:读取终端当前输出缓冲及退出状态 * - waitForTerminalExit:等待终端进程退出(带超时) * - killTerminal:强制终止终端进程 @@ -17,43 +16,44 @@ import { INodeLogger } from '@opensumi/ide-core-node'; import { ACPErrorCode } from './constants'; -// Re-export the permission callback type for convenience export const AcpTerminalHandlerToken = Symbol('AcpTerminalHandlerToken'); -export type TerminalPermissionCallback = ( - sessionId: string, - operation: 'command', - details: { - command: string; - args?: string[]; - cwd?: string; - title: string; - kind: string; - }, -) => Promise; - -export interface TerminalRequest { +export interface CreateTerminalRequest { sessionId: string; - command?: string; + command: string; args?: string[]; env?: Record; cwd?: string; outputByteLimit?: number; - terminalId?: string; - timeout?: number; } -export interface TerminalResponse { - error?: { - code: number; - message: string; - }; +export interface CreateTerminalResponse { terminalId?: string; - output?: string; - truncated?: boolean; - exitStatus?: number | null; - exitCode?: number; - signal?: string; + error?: { message: string }; +} + +export interface IAcpTerminalHandler { + createTerminal(req: CreateTerminalRequest): Promise; + getTerminalOutput( + terminalId: string, + sessionId: string, + ): Promise<{ + output?: string; + truncated?: boolean; + exitStatus?: number; + error?: { message: string }; + }>; + waitForTerminalExit( + terminalId: string, + sessionId: string, + ): Promise<{ + exitCode?: number; + signal?: string; + error?: { message: string }; + }>; + killTerminal(terminalId: string, sessionId: string): Promise<{ error?: { message: string } }>; + releaseTerminal(terminalId: string, sessionId: string): Promise<{ error?: { message: string } }>; + releaseSessionTerminals(sessionId: string): Promise; } interface TerminalSession { @@ -69,20 +69,12 @@ interface TerminalSession { } @Injectable() -export class AcpTerminalHandler { +export class AcpTerminalHandler implements IAcpTerminalHandler { @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) { @@ -90,7 +82,7 @@ export class AcpTerminalHandler { } } - async createTerminal(request: TerminalRequest): Promise { + async createTerminal(request: CreateTerminalRequest): Promise { const startTime = Date.now(); this.logger?.log( `[AcpTerminalHandler] createTerminal called, sessionId=${request.sessionId}, command=${ @@ -102,44 +94,17 @@ export class AcpTerminalHandler { 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() - }`, + `[AcpTerminalHandler] Spawning PTY process: command=${request.command}, cwd=${request.cwd || process.cwd()}`, ); // Create PTY process using node-pty - const ptyProcess = pty.spawn(request.command || '/bin/sh', request.args || [], { + const ptyProcess = pty.spawn(request.command, request.args || [], { name: 'xterm-256color', cwd: request.cwd || process.cwd(), env, @@ -198,34 +163,39 @@ export class AcpTerminalHandler { 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 || ''); + async getTerminalOutput( + terminalId: string, + sessionId: string, + ): Promise<{ + output?: string; + truncated?: boolean; + exitStatus?: number; + error?: { message: string }; + }> { + this.logger?.debug(`[AcpTerminalHandler] getTerminalOutput called, terminalId=${terminalId}`); + + const terminalSession = this.terminals.get(terminalId); if (!terminalSession) { - this.logger?.warn(`[AcpTerminalHandler] Terminal not found: ${request.terminalId}`); + this.logger?.warn(`[AcpTerminalHandler] Terminal not found: ${terminalId}`); return { error: { - code: ACPErrorCode.RESOURCE_NOT_FOUND, message: 'Terminal not found', }, }; } - if (terminalSession.sessionId !== request.sessionId) { + if (terminalSession.sessionId !== sessionId) { this.logger?.warn( - `[AcpTerminalHandler] Session mismatch: expected ${terminalSession.sessionId}, got ${request.sessionId}`, + `[AcpTerminalHandler] Session mismatch: expected ${terminalSession.sessionId}, got ${sessionId}`, ); return { error: { - code: ACPErrorCode.SERVER_ERROR, message: 'Session mismatch', }, }; @@ -242,35 +212,36 @@ export class AcpTerminalHandler { return { output, truncated, - exitStatus: terminalSession.exited ? terminalSession.exitCode ?? 0 : null, + exitStatus: terminalSession.exited ? terminalSession.exitCode ?? 0 : undefined, }; } - 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 || ''); + async waitForTerminalExit( + terminalId: string, + sessionId: string, + ): Promise<{ + exitCode?: number; + signal?: string; + error?: { message: string }; + }> { + this.logger?.debug(`[AcpTerminalHandler] waitForTerminalExit called, terminalId=${terminalId}`); + + const terminalSession = this.terminals.get(terminalId); if (!terminalSession) { - this.logger?.warn(`[AcpTerminalHandler] Terminal not found: ${request.terminalId}`); + this.logger?.warn(`[AcpTerminalHandler] Terminal not found: ${terminalId}`); return { error: { - code: ACPErrorCode.RESOURCE_NOT_FOUND, message: 'Terminal not found', }, }; } - if (terminalSession.sessionId !== request.sessionId) { + if (terminalSession.sessionId !== sessionId) { this.logger?.warn( - `[AcpTerminalHandler] Session mismatch: expected ${terminalSession.sessionId}, got ${request.sessionId}`, + `[AcpTerminalHandler] Session mismatch: expected ${terminalSession.sessionId}, got ${sessionId}`, ); return { error: { - code: ACPErrorCode.SERVER_ERROR, message: 'Session mismatch', }, }; @@ -278,18 +249,16 @@ export class AcpTerminalHandler { // If already exited, return immediately if (terminalSession.exited) { - this.logger?.log( - `[AcpTerminalHandler] Terminal ${request.terminalId} already exited, code=${terminalSession.exitCode}`, - ); + this.logger?.log(`[AcpTerminalHandler] Terminal ${terminalId} already exited, code=${terminalSession.exitCode}`); return { exitCode: terminalSession.exitCode, }; } - this.logger?.log(`[AcpTerminalHandler] Waiting for terminal ${request.terminalId} to exit...`); + this.logger?.log(`[AcpTerminalHandler] Waiting for terminal ${terminalId} to exit...`); - // Wait for exit with timeout - const timeout = request.timeout ?? 30000; // 30s default + // Wait for exit with timeout (30s default) + const timeout = 30000; const waitStartTime = Date.now(); return new Promise((resolve) => { @@ -299,7 +268,7 @@ export class AcpTerminalHandler { clearTimeout(timeoutId); const waitDuration = Date.now() - waitStartTime; this.logger?.log( - `[AcpTerminalHandler] Terminal ${request.terminalId} exited after ${waitDuration}ms, code=${terminalSession.exitCode}`, + `[AcpTerminalHandler] Terminal ${terminalId} exited after ${waitDuration}ms, code=${terminalSession.exitCode}`, ); resolve({ exitCode: terminalSession.exitCode, @@ -311,31 +280,27 @@ export class AcpTerminalHandler { clearInterval(checkInterval); const waitDuration = Date.now() - waitStartTime; this.logger?.warn( - `[AcpTerminalHandler] waitForTerminalExit timeout after ${waitDuration}ms for terminal ${request.terminalId}`, + `[AcpTerminalHandler] waitForTerminalExit timeout after ${waitDuration}ms for terminal ${terminalId}`, ); - // Return null exitStatus to indicate still running - resolve({ - exitStatus: null, - }); + resolve({}); }, timeout); }); } - async killTerminal(request: TerminalRequest): Promise { - const terminalSession = this.terminals.get(request.terminalId || ''); + async killTerminal(terminalId: string, sessionId: string): Promise<{ error?: { message: string } }> { + this.logger?.log(`[AcpTerminalHandler] killTerminal() — terminalId=${terminalId}`); + const terminalSession = this.terminals.get(terminalId); if (!terminalSession) { return { error: { - code: ACPErrorCode.RESOURCE_NOT_FOUND, message: 'Terminal not found', }, }; } - if (terminalSession.sessionId !== request.sessionId) { + if (terminalSession.sessionId !== sessionId) { return { error: { - code: ACPErrorCode.SERVER_ERROR, message: 'Session mismatch', }, }; @@ -343,13 +308,11 @@ export class AcpTerminalHandler { // If already exited, just return success if (terminalSession.exited) { - return { - exitStatus: terminalSession.exitCode ?? 0, - }; + return {}; } try { - this.logger?.log(`Killing terminal ${request.terminalId}`); + this.logger?.log(`Killing terminal ${terminalId}`); terminalSession.killed = true; @@ -377,57 +340,53 @@ export class AcpTerminalHandler { terminalSession.exited = true; } - return { - exitCode: terminalSession.exitCode ?? -1, - }; + return {}; } 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 || ''); + async releaseTerminal(terminalId: string, sessionId: string): Promise<{ error?: { message: string } }> { + this.logger?.log(`[AcpTerminalHandler] releaseTerminal() — terminalId=${terminalId}`); + const terminalSession = this.terminals.get(terminalId); if (!terminalSession) { // Already released or doesn't exist return {}; } - if (terminalSession.sessionId !== request.sessionId) { + if (terminalSession.sessionId !== sessionId) { return { error: { - code: ACPErrorCode.SERVER_ERROR, message: 'Session mismatch', }, }; } try { - this.logger?.log(`Releasing terminal ${request.terminalId}`); + this.logger?.log(`Releasing terminal ${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); + this.logger?.warn(`Failed to kill pty process ${terminalId}:`, e); } } // Remove from tracking - this.terminals.delete(request.terminalId || ''); + this.terminals.delete(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', }, }; @@ -447,10 +406,7 @@ export class AcpTerminalHandler { } for (const terminalId of terminalsToRelease) { - await this.releaseTerminal({ - sessionId, - terminalId, - }); + await this.releaseTerminal(terminalId, sessionId); } this.logger?.log(`Released ${terminalsToRelease.length} terminals for session ${sessionId}`); diff --git a/packages/ai-native/src/node/acp/index.ts b/packages/ai-native/src/node/acp/index.ts index b74860ef98..1b2fc92a88 100644 --- a/packages/ai-native/src/node/acp/index.ts +++ b/packages/ai-native/src/node/acp/index.ts @@ -1,12 +1,35 @@ -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, AcpFileSystemHandlerToken } from './handlers/file-system.handler'; export { AcpTerminalHandler, AcpTerminalHandlerToken } from './handlers/terminal.handler'; export { AcpAgentRequestHandler, AcpAgentRequestHandlerToken } from './handlers/agent-request.handler'; export { AcpAgentService, AcpAgentServiceToken, IAcpAgentService } from './acp-agent.service'; -export { AcpPermissionCallerManager, AcpPermissionCallerManagerToken } from './acp-permission-caller.service'; +export { + AcpPermissionCallerService, + AcpPermissionCallerServiceToken, + AcpPermissionCallerManagerToken, +} from './acp-permission-caller.service'; +export { AcpThreadStatusCallerService, AcpThreadStatusCallerServiceToken } from './acp-thread-status-caller.service'; +export { + PermissionRoutingService, + PermissionRoutingServiceToken, + IPermissionRoutingService, +} from './permission-routing.service'; +export { + AcpThread, + AcpThreadToken, + IAcpThread, + ThreadStatus, + ToolCallStatus, + UserMessageEntry, + AssistantMessageEntry, + ToolCallEntry, + AgentThreadEntry, + AcpThreadEvent, + AcpThreadOptions, + AcpThreadFactory, + AcpThreadFactoryToken, + AcpThreadFactoryProvider, + AcpThreadRuntimeConfig, +} from './acp-thread'; +export { AcpWebMcpCallerService } from './acp-webmcp-caller.service'; +export { AcpWebMcpHandler } from './acp-webmcp-handler'; diff --git a/packages/ai-native/src/node/acp/permission-routing.service.ts b/packages/ai-native/src/node/acp/permission-routing.service.ts new file mode 100644 index 0000000000..e3c0a1936b --- /dev/null +++ b/packages/ai-native/src/node/acp/permission-routing.service.ts @@ -0,0 +1,77 @@ +import { Autowired, Injectable } from '@opensumi/di'; +import { INodeLogger } from '@opensumi/ide-core-node'; + +import { AcpPermissionCallerService } from './acp-permission-caller.service'; + +import type { + RequestPermissionRequest, + RequestPermissionResponse, +} from '@opensumi/ide-core-common/lib/types/ai-native/acp-types'; + +export const PermissionRoutingServiceToken = Symbol('PermissionRoutingServiceToken'); + +export interface IPermissionRoutingService { + /** Register a session so it can receive permission requests */ + registerSession(sessionId: string): void; + /** Unregister a session */ + unregisterSession(sessionId: string): void; + /** Route a permission request to the appropriate session */ + routePermissionRequest(params: RequestPermissionRequest, sessionId: string): Promise; +} + +/** + * Permission Routing Service (Node, singleton) + * + * Routes permission requests from AcpThread instances to the browser + * via AcpPermissionCallerService. Supports multi-session by validating + * the sessionId is in registered sessions, returning 'cancelled' if not. + * + * Each call to routePermissionRequest() independently executes + * this.permissionCallerService.requestPermission(params) — no global lock, + * concurrent requests run independently, each session's result is + * independently returned with no cross-contamination. + */ +@Injectable() +export class PermissionRoutingService implements IPermissionRoutingService { + @Autowired(AcpPermissionCallerService) + private readonly permissionCallerService: AcpPermissionCallerService; + + @Autowired(INodeLogger) + private readonly logger: INodeLogger; + + private readonly registeredSessions = new Set(); + + registerSession(sessionId: string): void { + this.registeredSessions.add(sessionId); + this.logger.debug(`[PermissionRouting] Registered session: ${sessionId}`); + } + + unregisterSession(sessionId: string): void { + this.registeredSessions.delete(sessionId); + this.logger.debug(`[PermissionRouting] Unregistered session: ${sessionId}`); + } + + async routePermissionRequest( + params: RequestPermissionRequest, + sessionId: string, + ): Promise { + if (!this.registeredSessions.has(sessionId)) { + this.logger.warn( + '[PermissionRouting] No registered session for request, returning cancelled. ' + + `Requested sessionId: ${sessionId}`, + ); + return { + outcome: { + outcome: 'cancelled' as const, + }, + }; + } + + this.logger.debug( + `[PermissionRouting] Routing permission request to session: ${sessionId}, ` + + `toolCall: ${params.toolCall.toolCallId}`, + ); + + return this.permissionCallerService.requestPermission(params, sessionId); + } +} diff --git a/packages/ai-native/src/node/index.ts b/packages/ai-native/src/node/index.ts index 1456684025..69ecc200c3 100644 --- a/packages/ai-native/src/node/index.ts +++ b/packages/ai-native/src/node/index.ts @@ -2,8 +2,10 @@ import { Injectable, Provider } from '@opensumi/di'; import { AIBackSerivcePath, AIBackSerivceToken, - AcpCliClientServiceToken, AcpPermissionServicePath, + AcpThreadStatusServicePath, + AcpWebMcpBridgePath, + AcpWebMcpCallerServiceToken, } from '@opensumi/ide-core-common'; import { NodeModule } from '@opensumi/ide-core-node'; @@ -17,15 +19,18 @@ import { AcpAgentServiceToken, AcpFileSystemHandler, AcpFileSystemHandlerToken, - AcpPermissionCallerManager, - AcpPermissionCallerManagerToken, + AcpPermissionCallerService, + AcpPermissionCallerServiceToken, AcpTerminalHandler, AcpTerminalHandlerToken, - CliAgentProcessManager, - CliAgentProcessManagerToken, + AcpThreadFactoryProvider, + AcpThreadStatusCallerService, + AcpThreadStatusCallerServiceToken, + AcpWebMcpCallerService, + PermissionRoutingService, + PermissionRoutingServiceToken, } 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'; import { OpenAICompatibleModel } from './openai-compatible/openai-compatible-language-model'; @@ -36,21 +41,13 @@ export class AINativeModule extends NodeModule { token: AIBackSerivceToken, useClass: AcpCliBackService, }, - { - token: AcpCliClientServiceToken, - useClass: AcpCliClientService, - }, - { - token: CliAgentProcessManagerToken, - useClass: CliAgentProcessManager, - }, { token: AcpAgentServiceToken, useClass: AcpAgentService, }, { - token: AcpPermissionCallerManagerToken, - useClass: AcpPermissionCallerManager, + token: AcpPermissionCallerServiceToken, + useClass: AcpPermissionCallerService, }, { token: ToolInvocationRegistryManager, @@ -72,6 +69,23 @@ export class AINativeModule extends NodeModule { token: AcpAgentRequestHandlerToken, useClass: AcpAgentRequestHandler, }, + // Thread factory for creating AcpThread instances + AcpThreadFactoryProvider, + // Permission routing for multi-session permission requests + { + token: PermissionRoutingServiceToken, + useClass: PermissionRoutingService, + }, + // Thread status notification caller (Node → Browser) + { + token: AcpThreadStatusCallerServiceToken, + useClass: AcpThreadStatusCallerService, + }, + // WebMCP bridge caller (Node → Browser) + { + token: AcpWebMcpCallerServiceToken, + useClass: AcpWebMcpCallerService, + }, // Language models for non-ACP fallback OpenAICompatibleModel, ]; @@ -91,7 +105,15 @@ export class AINativeModule extends NodeModule { }, { servicePath: AcpPermissionServicePath, - token: AcpPermissionCallerManagerToken, + token: AcpPermissionCallerServiceToken, + }, + { + servicePath: AcpThreadStatusServicePath, + token: AcpThreadStatusCallerServiceToken, + }, + { + servicePath: AcpWebMcpBridgePath, + token: AcpWebMcpCallerServiceToken, }, ]; } diff --git a/packages/ai-native/src/node/mcp-server.sse.ts b/packages/ai-native/src/node/mcp-server.sse.ts index aa9117e707..647cf59b2c 100644 --- a/packages/ai-native/src/node/mcp-server.sse.ts +++ b/packages/ai-native/src/node/mcp-server.sse.ts @@ -76,7 +76,7 @@ export class SSEMCPServer implements IMCPServer { } } - async callTool(toolName: string, toolCallId: string, arg_string: string) { + async callTool(toolName: string, toolCallId: string, arg_string: string): Promise { let args; try { args = JSON.parse(arg_string); @@ -97,7 +97,7 @@ export class SSEMCPServer implements IMCPServer { return this.client.callTool(params); } - async getTools() { + async getTools(): Promise { const originalTools = await this.client.listTools(); this.toolNameMap.clear(); const toolsArray = originalTools.tools || []; diff --git a/packages/ai-native/src/node/mcp-server.stdio.ts b/packages/ai-native/src/node/mcp-server.stdio.ts index 4634f4f989..25170ed1ee 100644 --- a/packages/ai-native/src/node/mcp-server.stdio.ts +++ b/packages/ai-native/src/node/mcp-server.stdio.ts @@ -91,7 +91,7 @@ export class StdioMCPServer implements IMCPServer { this.started = true; } - async callTool(toolName: string, toolCallId: string, arg_string: string) { + async callTool(toolName: string, toolCallId: string, arg_string: string): Promise { let args; try { args = JSON.parse(arg_string); @@ -112,7 +112,7 @@ export class StdioMCPServer implements IMCPServer { return this.client.callTool(params); } - async getTools() { + async getTools(): Promise { const originalTools = await this.client.listTools(); this.toolNameMap.clear(); // Process tool names to remove Chinese characters and create mapping diff --git a/packages/ai-native/src/node/openai-compatible/openai-compatible-language-model.ts b/packages/ai-native/src/node/openai-compatible/openai-compatible-language-model.ts index 03a33037c8..df140bd650 100644 --- a/packages/ai-native/src/node/openai-compatible/openai-compatible-language-model.ts +++ b/packages/ai-native/src/node/openai-compatible/openai-compatible-language-model.ts @@ -11,6 +11,11 @@ import { BaseLanguageModel } from '../base-language-model'; export class OpenAICompatibleModel extends BaseLanguageModel { protected initializeProvider(options: IAIBackServiceOption): OpenAICompatibleProvider { const apiKey = options.apiKey; + this.logger?.log( + `[OpenAICompatibleModel] initializeProvider: apiKey=${apiKey ? apiKey.slice(0, 8) + '***' : '(empty)'}, baseURL=${ + options.baseURL || 'default' + }`, + ); if (!apiKey) { throw new Error(`Please provide OpenAI API Key in preferences (${AINativeSettingSectionsId.OpenaiApiKey})`); } diff --git a/packages/core-browser/src/webmcp-polyfill.ts b/packages/core-browser/src/webmcp-polyfill.ts new file mode 100644 index 0000000000..6fe71c045b --- /dev/null +++ b/packages/core-browser/src/webmcp-polyfill.ts @@ -0,0 +1,102 @@ +/** + * WebMCP `navigator.modelContext` polyfill. + * + * Three runtime cases are handled, in priority order: + * + * 1. **Full native** — `modelContext` already exposes both `registerTool` and `executeTool`. + * Nothing to do. + * 2. **Chrome split API** — `modelContext.registerTool` is native, but execution methods live + * on `navigator.modelContextTesting` (`executeTool`/`listTools`). We attach `executeTool` + * and `getTools` adapters onto `modelContext` so legacy callers (tests, external agents + * that use `modelContext.executeTool`) keep working. The adapter handles the JSON + * string ⇄ object boundary: Chrome's native API takes/returns JSON strings; the polyfill + * contract is plain objects. + * 3. **No native API** — install a Map-backed shim that implements register + execute. + * External agents are expected to import the same module to get a working surface. + * + * Only the registration + execution surface is provided. SSE transport / session management + * is the agent's responsibility. + */ +import type { NavigatorModelContext, WebMCPTool } from './webmcp-types'; + +export { WebMCPTool, NavigatorModelContext } from './webmcp-types'; + +interface NativeModelContextTesting { + executeTool(name: string, argsJson: string): Promise; + listTools(): Array<{ name: string; description: string; inputSchema: string | object }>; +} + +declare global { + interface Navigator { + modelContext?: NavigatorModelContext; + modelContextTesting?: NativeModelContextTesting; + } +} + +export function ensureModelContext() { + const mc = navigator.modelContext as (NavigatorModelContext & { executeTool?: unknown }) | undefined; + const native = navigator.modelContextTesting; + + if (mc && typeof mc.registerTool === 'function' && typeof mc.executeTool === 'function') { + return; + } + + if (mc && typeof mc.registerTool === 'function' && native && typeof native.executeTool === 'function') { + const target = mc as NavigatorModelContext & { + executeTool?: NavigatorModelContext['executeTool']; + getTools?: NavigatorModelContext['getTools']; + }; + target.executeTool = async (name: string, args: unknown) => { + const raw = await native.executeTool(name, JSON.stringify(args ?? {})); + return typeof raw === 'string' ? JSON.parse(raw) : raw; + }; + target.getTools = () => { + const tools = native.listTools?.() || []; + return tools.map((t) => ({ + name: t.name, + description: t.description, + inputSchema: + typeof t.inputSchema === 'string' ? JSON.parse(t.inputSchema) : (t.inputSchema as WebMCPTool['inputSchema']), + })); + }; + return; + } + + const tools = new Map(); + + const ctx: NavigatorModelContext = { + registerTool(tool: WebMCPTool, options?: { signal?: AbortSignal }) { + tools.set(tool.name, { ...tool, signal: options?.signal }); + return { dispose: () => tools.delete(tool.name) }; + }, + + async executeTool(name: string, args: any) { + const tool = tools.get(name); + if (!tool) { + return { + success: false, + error: 'TOOL_NOT_FOUND', + details: `Tool "${name}" is not registered`, + }; + } + if (tool.signal?.aborted) { + return { + success: false, + error: 'TOOL_DISPOSED', + details: `Tool "${name}" has been disposed`, + }; + } + return tool.execute(args); + }, + + getTools() { + return Array.from(tools.values()).map((t) => ({ + name: t.name, + description: t.description, + inputSchema: t.inputSchema, + })); + }, + }; + + navigator.modelContext = ctx; +} diff --git a/packages/core-browser/src/webmcp-types.ts b/packages/core-browser/src/webmcp-types.ts new file mode 100644 index 0000000000..fadec7fab2 --- /dev/null +++ b/packages/core-browser/src/webmcp-types.ts @@ -0,0 +1,16 @@ +export interface WebMCPTool { + name: string; + description: string; + inputSchema: { + type: 'object'; + properties: Record; + required?: string[]; + }; + execute: (args: any) => Promise; +} + +export interface NavigatorModelContext { + registerTool(tool: WebMCPTool, options?: { signal?: AbortSignal }): { dispose(): void }; + executeTool(name: string, args: any): Promise; + getTools(): Omit[]; +} diff --git a/packages/core-common/src/settings/ai-native.ts b/packages/core-common/src/settings/ai-native.ts index ca4a08bd5b..3f26b492bc 100644 --- a/packages/core-common/src/settings/ai-native.ts +++ b/packages/core-common/src/settings/ai-native.ts @@ -47,6 +47,16 @@ export enum AINativeSettingSectionsId { */ AgentConfigs = 'ai.native.agent.configs', + /** + * ACP: Node.js runtime path for agent subprocesses + */ + NodePath = 'ai-native.acp.nodePath', + + /** + * ACP: Per-agent spawn parameter overrides (command/args/env) + */ + AgentConfigsOverride = 'ai-native.acp.agents', + /** * Default Agent Type */ diff --git a/packages/core-common/src/types/ai-native/acp-types.ts b/packages/core-common/src/types/ai-native/acp-types.ts index 48fb57f12b..8de3f9dad5 100644 --- a/packages/core-common/src/types/ai-native/acp-types.ts +++ b/packages/core-common/src/types/ai-native/acp-types.ts @@ -42,9 +42,14 @@ export type { AvailableCommandsUpdate, CancelNotification, ClientCapabilities, + CloseSessionRequest, + CloseSessionResponse, ContentBlock, CreateTerminalRequest, CreateTerminalResponse, + EnvVariable, + ForkSessionRequest, + ForkSessionResponse, Implementation, InitializeRequest, InitializeResponse, @@ -57,6 +62,10 @@ export type { NewSessionResponse, PermissionOption, PermissionOptionKind, + Plan, + PlanEntry, + PlanEntryPriority, + PlanEntryStatus, PromptCapabilities, PromptRequest, PromptResponse, @@ -66,16 +75,26 @@ export type { ReleaseTerminalResponse, RequestPermissionRequest, RequestPermissionResponse, + ResumeSessionRequest, + ResumeSessionResponse, SessionCapabilities, SessionInfo, SessionMode, SessionModeState, SessionNotification, + SetSessionConfigOptionRequest, + SetSessionConfigOptionResponse, SetSessionModeRequest, SetSessionModeResponse, + SetSessionModelRequest, + SetSessionModelResponse, TerminalOutputRequest, TerminalOutputResponse, + ToolCall, + ToolCallContent, + ToolCallId, ToolCallLocation, + ToolCallStatus, ToolCallUpdate, WaitForTerminalExitRequest, WaitForTerminalExitResponse, @@ -83,6 +102,11 @@ export type { WriteTextFileResponse, KillTerminalCommandResponse, KillTerminalCommandRequest, + HttpHeader, + McpServer, + McpServerHttp, + McpServerSse, + McpServerStdio, ToolKind, } from '@agentclientprotocol/sdk'; @@ -123,129 +147,47 @@ export interface IAcpPermissionService { 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'; - -/** - * 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; +export const AcpThreadStatusServicePath = 'AcpThreadStatusServicePath'; - /** - * 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; +export interface IAcpThreadStatusService { + $onThreadStatusChange(sessionId: string, status: string): Promise; +} - /** - * Register a disconnect handler, called when the connection is lost - * @returns Unsubscribe function - */ - onDisconnect(handler: () => void): () => void; +// WebMCP Group types for ACP extension methods +export const AcpWebMcpBridgePath = 'AcpWebMcpBridgePath'; - /** - * Get the negotiated protocol version - */ - getNegotiatedProtocolVersion(): number | null; +export interface WebMcpToolDef { + method: string; // "_opensumi/file/read" + description: string; + inputSchema: Record; +} - /** - * Get agent capabilities from initialize response - */ - getAgentCapabilities(): AgentCapabilities | null; +export interface WebMcpGroupDef { + name: string; + description: string; + defaultLoaded: boolean; + tools: WebMcpToolDef[]; +} - /** - * Get agent info from initialize response - */ - getAgentInfo(): Implementation | null; +export interface WebMcpToolResult { + success: boolean; + result?: unknown; + error?: string; // machine-readable error code + details?: string; // human-readable error description +} - /** - * Get available authentication methods - */ - getAuthMethods(): AuthMethod[]; +export interface WebMcpGroupInfo { + name: string; + description: string; + toolCount: number; + loaded: boolean; +} - /** - * Get available session modes - */ - getSessionModes(): SessionModeState | null; +export interface IAcpWebMcpBridgeService { + $getGroupDefinitions(): Promise; + $executeTool(group: string, tool: string, params: Record): Promise; } -/** - * Symbol token for dependency injection - */ -export const AcpCliClientServiceToken = Symbol('AcpCliClientServiceToken'); +export const AcpWebMcpCallerServiceToken = Symbol('AcpWebMcpCallerServiceToken'); +export const AcpWebMcpHandlerToken = Symbol('AcpWebMcpHandlerToken'); +export const WebMcpGroupRegistryToken = Symbol('WebMcpGroupRegistryToken'); diff --git a/packages/core-common/src/types/ai-native/agent-types.ts b/packages/core-common/src/types/ai-native/agent-types.ts index a2960bf1c2..34ab9899df 100644 --- a/packages/core-common/src/types/ai-native/agent-types.ts +++ b/packages/core-common/src/types/ai-native/agent-types.ts @@ -3,6 +3,8 @@ * Centralized configuration for supported CLI agents */ +import type { EnvVariable, McpServer } from './acp-types'; + // ACP Agent 类型 export type ACPAgentType = 'qwen' | 'claude-agent-acp'; @@ -55,19 +57,40 @@ export function getSupportedAgentTypes(): ACPAgentType[] { /** * Configuration for spawning and running the ACP CLI agent process. * Used to initialize the agent connection and process, not to configure individual sessions. + * Field names and env structure are aligned with @agentclientprotocol/sdk conventions. */ export interface AgentProcessConfig { /** - * CLI command to start the agent + * Stable agent identifier (e.g., 'claude-agent-acp'). + * Used for per-agent preference lookup and diagnostics. + */ + agentId: string; + /** + * CLI command to start the agent (already resolved by browser). */ command: string; /** - * Arguments passed to the agent + * Arguments passed to the agent. */ args: string[]; - workspaceDir: string; - env?: Record; - enablePermissionConfirmation?: boolean; + /** + * Working directory (absolute path). + * Named `cwd` to match ACP SDK CreateTerminalRequest. + */ + cwd: string; + /** + * Environment variables for the agent process. + * Structure matches ACP SDK EnvVariable (array of {name, value}). + */ + env?: EnvVariable[]; + /** + * Node.js executable path from preference. Node layer continues fallback. + */ + nodePath?: string; + /** + * MCP servers to pass into ACP session/new, session/load, and related session operations. + */ + mcpServers?: McpServer[]; } /** @@ -77,6 +100,8 @@ export interface AgentProcessConfig { */ export const IACPConfigProvider = Symbol('IACPConfigProvider'); +export { EnvVariable } from './acp-types'; + export interface IACPConfigProvider { /** * Build the AgentProcessConfig for ACP operations. diff --git a/packages/core-common/src/types/ai-native/index.ts b/packages/core-common/src/types/ai-native/index.ts index 479236ea15..763fd1a985 100644 --- a/packages/core-common/src/types/ai-native/index.ts +++ b/packages/core-common/src/types/ai-native/index.ts @@ -466,6 +466,18 @@ export interface IChatReasoning { kind: 'reasoning'; } +/** + * Thread status for ACP agent sessions. + * Mirrors the server-side AcpThread ThreadStatus type. + */ +export type ThreadStatus = 'idle' | 'working' | 'awaiting_prompt' | 'auth_required' | 'errored' | 'disconnected'; + +export interface IChatThreadStatus { + kind: 'threadStatus'; + threadStatus: ThreadStatus; + sessionId: string; +} + export type IChatProgress = | IChatContent | IChatMarkdownContent @@ -473,7 +485,8 @@ export type IChatProgress = | IChatTreeData | IChatComponent | IChatToolContent - | IChatReasoning; + | IChatReasoning + | IChatThreadStatus; export interface IChatMessage { role: ChatMessageRole; diff --git a/packages/core-node/src/connection.ts b/packages/core-node/src/connection.ts index 4fc0cbf63d..2c67f67325 100644 --- a/packages/core-node/src/connection.ts +++ b/packages/core-node/src/connection.ts @@ -149,6 +149,12 @@ export function bindModuleBackService( if (!serviceInstance.rpcClient) { serviceInstance.rpcClient = [stub]; } + // Allow services to expose a static method for sharing the RPC stub + // with parent-injector consumers (e.g. PermissionRoutingService). + const ctor = serviceInstance.constructor as any; + if (typeof ctor?.setStaticRpcClient === 'function') { + ctor.setStaticRpcClient(stub); + } } } diff --git a/packages/i18n/src/common/en-US.lang.ts b/packages/i18n/src/common/en-US.lang.ts index 332eda35a6..40f0f18a99 100644 --- a/packages/i18n/src/common/en-US.lang.ts +++ b/packages/i18n/src/common/en-US.lang.ts @@ -1510,6 +1510,7 @@ export const localizationBundle = { 'aiNative.chat.welcome.loading.text': 'Initializing...', 'aiNative.chat.acp.initializing.text': 'Initializing ACP service...', + 'aiNative.acp.permissionPending': 'Permission pending', 'aiNative.chat.ai.assistant.limit.message': '{0} earliest messages are dropped due to the input token limit', 'aiNative.inlineDiff.acceptAll': 'Accept All', 'aiNative.inlineDiff.rejectAll': 'Reject All', diff --git a/packages/i18n/src/common/zh-CN.lang.ts b/packages/i18n/src/common/zh-CN.lang.ts index 914f03c115..5e04625926 100644 --- a/packages/i18n/src/common/zh-CN.lang.ts +++ b/packages/i18n/src/common/zh-CN.lang.ts @@ -1224,7 +1224,7 @@ export const localizationBundle = { // #region AI Native 'aiNative.chat.ai.assistant.name': 'AI 研发助手', 'aiNative.chat.input.placeholder.default': '可以问我任何问题,输入 @ 可引用内容', - 'aiNative.chat.input.placeholder.acp': '向 claude-agent-acp 发送消息,输入 @ 引用上下文,/ 使用命令', + 'aiNative.chat.input.placeholder.acp': '输入 @ 添加上下文,/ 唤起命令', 'aiNative.chat.stop.immediately': '我先不想了,有需要可以随时问我', 'aiNative.chat.error.response': '当前与我互动的人太多,请稍后再试,感谢您的理解与支持', 'aiNative.chat.code.insert': '插入代码', @@ -1278,6 +1278,7 @@ export const localizationBundle = { 'aiNative.chat.welcome.loading.text': '初始化中...', 'aiNative.chat.acp.initializing.text': '正在初始化 ACP 服务...', + 'aiNative.acp.permissionPending': '权限请求等待中', 'aiNative.chat.ai.assistant.limit.message': '{0} 条最早的消息因输入 Tokens 限制而被丢弃', 'aiNative.inlineDiff.acceptAll': '接受全部', 'aiNative.inlineDiff.rejectAll': '拒绝全部', diff --git a/packages/startup/entry/sample-modules/ai-native/WelcomePage.tsx b/packages/startup/entry/sample-modules/ai-native/WelcomePage.tsx index 81c735e5b4..528d4556e3 100644 --- a/packages/startup/entry/sample-modules/ai-native/WelcomePage.tsx +++ b/packages/startup/entry/sample-modules/ai-native/WelcomePage.tsx @@ -25,9 +25,6 @@ export const ExampleWelcomePage: React.FC = ({ onSend }) => {

{localize('aiNative.chat.ai.assistant.name')}

-

- {localize('aiNative.chat.welcome.loading.text') || 'Your AI-powered coding assistant'} -

diff --git a/packages/terminal-next/src/browser/index.ts b/packages/terminal-next/src/browser/index.ts index d77d015636..8bb7d8cc04 100644 --- a/packages/terminal-next/src/browser/index.ts +++ b/packages/terminal-next/src/browser/index.ts @@ -1,4 +1,4 @@ -import { Injectable, Provider } from '@opensumi/di'; +import { IDisposable, Injectable, Provider } from '@opensumi/di'; import { BrowserModule } from '@opensumi/ide-core-browser'; import { @@ -49,9 +49,12 @@ import { TerminalSearchService } from './terminal.search'; import { NodePtyTerminalService } from './terminal.service'; import { TerminalTheme } from './terminal.theme'; import { TerminalGroupViewService } from './terminal.view'; +import { registerTerminalWebMCPTools } from './webmcp-tools.registry'; @Injectable() export class TerminalNextModule extends BrowserModule { + private webMCPDisposable: IDisposable; + providers: Provider[] = [ TerminalLifeCycleContribution, TerminalRenderContribution, @@ -140,4 +143,12 @@ export class TerminalNextModule extends BrowserModule { clientToken: EnvironmentVariableServiceToken, }, ]; + + async onDidStart() { + this.webMCPDisposable = registerTerminalWebMCPTools(this.app.injector); + } + + onWillStop() { + this.webMCPDisposable?.dispose(); + } } diff --git a/packages/terminal-next/src/browser/webmcp-tools.registry.ts b/packages/terminal-next/src/browser/webmcp-tools.registry.ts new file mode 100644 index 0000000000..ed9502c5fc --- /dev/null +++ b/packages/terminal-next/src/browser/webmcp-tools.registry.ts @@ -0,0 +1,540 @@ +/** + * WebMCP tool registry for the terminal-next module. + * + * Registers browser-side tools on `navigator.modelContext` that allow an external + * AI agent to interact with the terminal panel — creating terminals, sending commands, + * listing sessions, and querying terminal state. + * + * Tools follow the naming convention: terminal_ + * + * PHASE 1: Register core terminal operations with hand-crafted schemas. + * Phase 2: Later, add more granular tools and refine descriptions. + */ +import { IDisposable, Injector } from '@opensumi/di'; +import { ensureModelContext } from '@opensumi/ide-core-browser/lib/webmcp-polyfill'; + +import { ITerminalService } from '../common'; +import { ITerminalApiService } from '../common/api'; +import { ITerminalController } from '../common/controller'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function tryGetService(container: Injector, token: symbol): T | null { + try { + return container.get(token) as T; + } catch { + return null; + } +} + +function classifyError(err: unknown): string { + if (typeof err === 'object' && err !== null) { + const name = (err as Error).name || ''; + if (name.includes('Timeout') || name.includes('timeout')) { + return 'RPC_TIMEOUT'; + } + if (name.includes('Injector') || name.includes('DI')) { + return 'DI_ERROR'; + } + if (name.includes('Permission') || name.includes('denied')) { + return 'PERMISSION_DENIED'; + } + if (name.includes('Abort')) { + return 'ABORTED'; + } + } + return 'EXECUTION_ERROR'; +} + +function safeErrorMessage(err: unknown): string { + const msg = err instanceof Error ? err.message : String(err); + return msg + .replace(/[A-Za-z_]*token[A-Za-z_]*[:=\s]+["']?[A-Za-z0-9+/=]+["']?/gi, '[REDACTED]') + .replace(/[A-Za-z_]*key[A-Za-z_]*[:=\s]+["']?[A-Za-z0-9+/=]+["']?/gi, '[REDACTED]') + .substring(0, 200); +} + +// --------------------------------------------------------------------------- +// Registry +// --------------------------------------------------------------------------- + +export function registerTerminalWebMCPTools(container: Injector): IDisposable { + ensureModelContext(); + + const ctx = navigator.modelContext!; + const controller = new AbortController(); + + // ----- terminal_list ----- + ctx.registerTool( + { + name: 'terminal_list', + description: + 'List all open terminal sessions. Returns an array of terminal info objects including id, name, isActive, and pid. Use this to discover existing terminals before sending commands.', + inputSchema: { + type: 'object', + properties: {}, + }, + execute: async () => { + const terminalApi = tryGetService(container, ITerminalApiService); + if (!terminalApi) { + return { + success: false, + error: 'SERVICE_UNAVAILABLE', + details: 'ITerminalApiService not registered in DI container', + }; + } + try { + const terminals = terminalApi.terminals; + return { + success: true, + result: terminals.map((t) => ({ + id: t.id, + name: t.name, + isActive: t.isActive, + })), + }; + } catch (err) { + return { + success: false, + error: classifyError(err), + details: safeErrorMessage(err), + }; + } + }, + }, + { signal: controller.signal }, + ); + + // ----- terminal_create ----- + ctx.registerTool( + { + name: 'terminal_create', + description: + 'Create a new terminal session. Optionally specify a shell path or working directory. Returns the terminal id. Use this to open a new terminal for running commands.', + inputSchema: { + type: 'object', + properties: { + cwd: { + type: 'string', + description: 'Working directory for the new terminal. Defaults to workspace root.', + }, + shellPath: { + type: 'string', + description: 'Shell executable path (e.g. "/bin/bash", "/bin/zsh"). Defaults to system default.', + }, + name: { + type: 'string', + description: 'Display name for the terminal.', + }, + }, + }, + execute: async (args?: { cwd?: string; shellPath?: string; name?: string }) => { + const terminalController = tryGetService(container, ITerminalController); + if (!terminalController) { + return { + success: false, + error: 'SERVICE_UNAVAILABLE', + details: 'ITerminalController not registered in DI container', + }; + } + try { + await terminalController.viewReady.promise; + const client = await terminalController.createTerminal({ + config: args?.shellPath ? { executable: args.shellPath } : undefined, + cwd: args?.cwd, + }); + return { + success: true, + result: { + id: client.id, + name: client.name, + }, + }; + } catch (err) { + return { + success: false, + error: classifyError(err), + details: safeErrorMessage(err), + }; + } + }, + }, + { signal: controller.signal }, + ); + + // ----- terminal_executeCommand ----- + ctx.registerTool( + { + name: 'terminal_executeCommand', + description: + 'Send a text command to a specific terminal session identified by terminalId. The text is typed into the terminal as-is. To execute the command, include a trailing newline (\\n). Get valid terminalIds from terminal_list.', + inputSchema: { + type: 'object', + properties: { + terminalId: { + type: 'string', + description: 'The terminal session ID. Get valid IDs from terminal_list.', + }, + command: { + type: 'string', + description: 'The text to send to the terminal. Append "\\n" to execute the command.', + }, + }, + required: ['terminalId', 'command'], + }, + execute: async (args: { terminalId: string; command: string }) => { + if (!args.terminalId || !args.command) { + return { + success: false, + error: 'INVALID_INPUT', + details: 'terminalId and command are required', + }; + } + const terminalApi = tryGetService(container, ITerminalApiService); + if (!terminalApi) { + return { + success: false, + error: 'SERVICE_UNAVAILABLE', + details: 'ITerminalApiService not registered in DI container', + }; + } + try { + terminalApi.sendText(args.terminalId, args.command); + return { + success: true, + result: { + terminalId: args.terminalId, + commandSent: args.command, + }, + }; + } catch (err) { + return { + success: false, + error: classifyError(err), + details: safeErrorMessage(err), + }; + } + }, + }, + { signal: controller.signal }, + ); + + // ----- terminal_show ----- + ctx.registerTool( + { + name: 'terminal_show', + description: + 'Show/focus a specific terminal session in the terminal panel. Use this to bring a terminal into view.', + inputSchema: { + type: 'object', + properties: { + terminalId: { + type: 'string', + description: 'The terminal session ID to show. Get valid IDs from terminal_list.', + }, + }, + required: ['terminalId'], + }, + execute: async (args: { terminalId: string }) => { + if (!args.terminalId) { + return { + success: false, + error: 'INVALID_INPUT', + details: 'terminalId is required', + }; + } + const terminalApi = tryGetService(container, ITerminalApiService); + if (!terminalApi) { + return { + success: false, + error: 'SERVICE_UNAVAILABLE', + details: 'ITerminalApiService not registered in DI container', + }; + } + try { + terminalApi.showTerm(args.terminalId); + return { success: true, result: { terminalId: args.terminalId } }; + } catch (err) { + return { + success: false, + error: classifyError(err), + details: safeErrorMessage(err), + }; + } + }, + }, + { signal: controller.signal }, + ); + + // ----- terminal_getProcessId ----- + ctx.registerTool( + { + name: 'terminal_getProcessId', + description: + 'Get the OS process ID (PID) of the shell process running in a terminal session. Returns undefined if the process has exited.', + inputSchema: { + type: 'object', + properties: { + terminalId: { + type: 'string', + description: 'The terminal session ID. Get valid IDs from terminal_list.', + }, + }, + required: ['terminalId'], + }, + execute: async (args: { terminalId: string }) => { + if (!args.terminalId) { + return { + success: false, + error: 'INVALID_INPUT', + details: 'terminalId is required', + }; + } + const terminalApi = tryGetService(container, ITerminalApiService); + if (!terminalApi) { + return { + success: false, + error: 'SERVICE_UNAVAILABLE', + details: 'ITerminalApiService not registered in DI container', + }; + } + try { + const pid = await terminalApi.getProcessId(args.terminalId); + return { + success: true, + result: { + terminalId: args.terminalId, + pid: pid ?? null, + }, + }; + } catch (err) { + return { + success: false, + error: classifyError(err), + details: safeErrorMessage(err), + }; + } + }, + }, + { signal: controller.signal }, + ); + + // ----- terminal_dispose ----- + ctx.registerTool( + { + name: 'terminal_dispose', + description: + 'Close/kill a terminal session and its underlying shell process. Use this to clean up terminals that are no longer needed.', + inputSchema: { + type: 'object', + properties: { + terminalId: { + type: 'string', + description: 'The terminal session ID to close. Get valid IDs from terminal_list.', + }, + }, + required: ['terminalId'], + }, + execute: async (args: { terminalId: string }) => { + if (!args.terminalId) { + return { + success: false, + error: 'INVALID_INPUT', + details: 'terminalId is required', + }; + } + const terminalApi = tryGetService(container, ITerminalApiService); + if (!terminalApi) { + return { + success: false, + error: 'SERVICE_UNAVAILABLE', + details: 'ITerminalApiService not registered in DI container', + }; + } + try { + terminalApi.removeTerm(args.terminalId); + return { success: true, result: { terminalId: args.terminalId, status: 'disposed' } }; + } catch (err) { + return { + success: false, + error: classifyError(err), + details: safeErrorMessage(err), + }; + } + }, + }, + { signal: controller.signal }, + ); + + // ----- terminal_resize ----- + ctx.registerTool( + { + name: 'terminal_resize', + description: 'Resize a terminal session to the specified number of columns (width) and rows (height).', + inputSchema: { + type: 'object', + properties: { + terminalId: { + type: 'string', + description: 'The terminal session ID. Get valid IDs from terminal_list.', + }, + cols: { + type: 'number', + description: 'Number of columns (character width) for the terminal.', + }, + rows: { + type: 'number', + description: 'Number of rows (character height) for the terminal.', + }, + }, + required: ['terminalId', 'cols', 'rows'], + }, + execute: async (args: { terminalId: string; cols: number; rows: number }) => { + if (!args.terminalId || !args.cols || !args.rows) { + return { + success: false, + error: 'INVALID_INPUT', + details: 'terminalId, cols, and rows are required', + }; + } + const terminalService = tryGetService(container, ITerminalService); + if (!terminalService) { + return { + success: false, + error: 'SERVICE_UNAVAILABLE', + details: 'ITerminalService not registered in DI container', + }; + } + try { + await terminalService.resize(args.terminalId, args.cols, args.rows); + return { success: true, result: { terminalId: args.terminalId, cols: args.cols, rows: args.rows } }; + } catch (err) { + return { + success: false, + error: classifyError(err), + details: safeErrorMessage(err), + }; + } + }, + }, + { signal: controller.signal }, + ); + + // ----- terminal_getOS ----- + ctx.registerTool( + { + name: 'terminal_getOS', + description: + 'Get the operating system type of the terminal backend (e.g. "Linux", "macOS", "Windows"). Useful for writing platform-specific commands.', + inputSchema: { + type: 'object', + properties: {}, + }, + execute: async () => { + const terminalService = tryGetService(container, ITerminalService); + if (!terminalService) { + return { + success: false, + error: 'SERVICE_UNAVAILABLE', + details: 'ITerminalService not registered in DI container', + }; + } + try { + const os = await terminalService.getOS(); + return { success: true, result: { os } }; + } catch (err) { + return { + success: false, + error: classifyError(err), + details: safeErrorMessage(err), + }; + } + }, + }, + { signal: controller.signal }, + ); + + // ----- terminal_getProfiles ----- + ctx.registerTool( + { + name: 'terminal_getProfiles', + description: + 'Get the list of available terminal shell profiles (e.g. bash, zsh, PowerShell). Use the profile name with terminal_create to open a specific shell.', + inputSchema: { + type: 'object', + properties: { + autoDetect: { + type: 'boolean', + description: 'Whether to auto-detect available shells. Defaults to true.', + }, + }, + }, + execute: async (args?: { autoDetect?: boolean }) => { + const terminalService = tryGetService(container, ITerminalService); + if (!terminalService) { + return { + success: false, + error: 'SERVICE_UNAVAILABLE', + details: 'ITerminalService not registered in DI container', + }; + } + try { + const profiles = await terminalService.getProfiles(args?.autoDetect ?? true); + return { + success: true, + result: profiles.map((p: any) => ({ + profileName: p.profileName, + path: p.path, + isAutoDetected: p.isAutoDetected, + })), + }; + } catch (err) { + return { + success: false, + error: classifyError(err), + details: safeErrorMessage(err), + }; + } + }, + }, + { signal: controller.signal }, + ); + + // ----- terminal_showPanel ----- + ctx.registerTool( + { + name: 'terminal_showPanel', + description: + 'Show/open the terminal panel in the IDE. Use this to ensure the terminal panel is visible before interacting with terminals.', + inputSchema: { + type: 'object', + properties: {}, + }, + execute: async () => { + const terminalController = tryGetService(container, ITerminalController); + if (!terminalController) { + return { + success: false, + error: 'SERVICE_UNAVAILABLE', + details: 'ITerminalController not registered in DI container', + }; + } + try { + terminalController.showTerminalPanel(); + return { success: true, result: { status: 'shown' } }; + } catch (err) { + return { + success: false, + error: classifyError(err), + details: safeErrorMessage(err), + }; + } + }, + }, + { signal: controller.signal }, + ); + + return { dispose: () => controller.abort() }; +} diff --git a/scripts/verify-mcp-server.js b/scripts/verify-mcp-server.js new file mode 100644 index 0000000000..3ab6161083 --- /dev/null +++ b/scripts/verify-mcp-server.js @@ -0,0 +1,91 @@ +#!/usr/bin/env node +'use strict'; + +const { Server } = require('@modelcontextprotocol/sdk/server/index.js'); +const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js'); +const { CallToolRequestSchema, ListToolsRequestSchema } = require('@modelcontextprotocol/sdk/types.js'); + +const server = new Server( + { + name: 'opensumi-acp-verify-mcp', + version: '0.1.0', + }, + { + capabilities: { + tools: {}, + }, + }, +); + +server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: 'verify_echo', + description: 'Echo a message back. Use this to verify that the OpenSumi ACP MCP bridge can call tools.', + inputSchema: { + type: 'object', + properties: { + message: { + type: 'string', + description: 'Message to echo.', + }, + }, + required: ['message'], + additionalProperties: false, + }, + }, + { + name: 'verify_workspace', + description: 'Return the MCP server process cwd and selected environment values for verification.', + inputSchema: { + type: 'object', + properties: {}, + additionalProperties: false, + }, + }, + ], +})); + +server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args = {} } = request.params; + + if (name === 'verify_echo') { + return { + content: [ + { + type: 'text', + text: `echo:${String(args.message ?? '')}`, + }, + ], + }; + } + + if (name === 'verify_workspace') { + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + cwd: process.cwd(), + verifyEnv: process.env.OPENSUMI_MCP_VERIFY || '', + }), + }, + ], + }; + } + + return { + content: [ + { + type: 'text', + text: `Unknown tool: ${name}`, + }, + ], + isError: true, + }; +}); + +server.connect(new StdioServerTransport()).catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/test/bdd/message-flow.scenario.md b/test/bdd/message-flow.scenario.md new file mode 100644 index 0000000000..7b692fa06b --- /dev/null +++ b/test/bdd/message-flow.scenario.md @@ -0,0 +1,27 @@ +# Scenario: Message flow — send, receive, verify state + +**Trigger:** `**/chat/chat.api.service.ts` or `**/chat/chat-manager.service.acp.ts` + +## Given + +- Browser is at http://localhost:8080 +- WebMCP is available (`navigator.modelContext` exists) +- ACP tools registered: `acp_createSession`, `acp_sendMessage`, `acp_getSessionState` + +## When + +1. `webmcp`: `acp_createSession` → capture `sessionId` +2. `webmcp`: `acp_getSessionState` → record initial state (requestCount = 0, threadStatus = "idle") +3. `webmcp`: `acp_sendMessage({ sessionId: "{sessionId}", message: "hello" })` +4. `webmcp`: `acp_getSessionState` → check state after sending (within 5s) +5. Wait 15 seconds for agent response +6. `webmcp`: `acp_getSessionState` → check final state +7. `cdp-snapshot`: capture current page accessibility tree + +## Then + +- Step 2: threadStatus = "idle", requestCount = 0 +- Step 3: returns `status: "message_sent"` +- Step 4: requestCount >= 1 (message queued), threadStatus transitions to "working" +- Step 6: requestCount >= 1, threadStatus = "awaiting_prompt" (agent responded) +- Step 7: CDP snapshot does not show error state in chat panel diff --git a/test/bdd/permission-dialog.scenario.md b/test/bdd/permission-dialog.scenario.md new file mode 100644 index 0000000000..73551be07f --- /dev/null +++ b/test/bdd/permission-dialog.scenario.md @@ -0,0 +1,27 @@ +# Scenario: Permission dialog — detect and handle + +**Trigger:** `**/acp/permission-bridge.service.ts` or `**/acp/webmcp-tools.registry.ts` + +## Given + +- Browser is at http://localhost:8080 +- WebMCP is available (`navigator.modelContext` exists) +- ACP tools registered: `acp_createSession`, `acp_sendMessage`, `acp_getPermissionDialogState`, `acp_handlePermissionDialog` + +## When + +1. `webmcp`: `acp_createSession` → capture `sessionId` +2. `webmcp`: `acp_getPermissionDialogState` → baseline: activeDialogCount = 0 +3. `webmcp`: `acp_sendMessage({ sessionId: "{sessionId}", message: "create a file named test.txt with content 'hello'" })` +4. Wait 10 seconds for agent to process and potentially trigger permission request +5. `webmcp`: `acp_getPermissionDialogState` → check for active dialog +6. If `activeDialogCount > 0`: + - `webmcp`: `acp_handlePermissionDialog({ requestId: "{requestId}", optionId: "allow_once" })` +7. `webmcp`: `acp_getPermissionDialogState` → verify dialog cleared + +## Then + +- Step 2: activeDialogCount = 0 (no pending dialogs initially) +- Step 5: if agent triggers file write, activeDialogCount >= 1, requestId is populated +- Step 6: permission dialog handled, returns requestId and optionId +- Step 7: activeDialogCount returns to 0 (dialog dismissed) diff --git a/yarn.lock b/yarn.lock index 0f0be2d976..6f8864fc3e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3465,8 +3465,8 @@ __metadata: react-highlight: "npm:^0.15.0" tiktoken: "npm:1.0.12" web-tree-sitter: "npm:0.22.6" - zod: "npm:^3.23.8" - zod-to-json-schema: "npm:^3.24.1" + zod: "npm:^3.25.0 || ^4.0.0" + zod-to-json-schema: "npm:^3.25.0" languageName: unknown linkType: soft @@ -26222,9 +26222,25 @@ __metadata: languageName: node linkType: hard +"zod-to-json-schema@npm:^3.25.0": + version: 3.25.2 + resolution: "zod-to-json-schema@npm:3.25.2" + peerDependencies: + zod: ^3.25.28 || ^4 + checksum: 10/7035328654113f1a0b8e4c2d34a06f918c93650ef8a50d4fb30ad8f22e47d5762c163af9c82494756b34776bae3c41c26cfc6945105b0eee7dceb528cc07e665 + languageName: node + linkType: hard + "zod@npm:^3.23.8": version: 3.24.1 resolution: "zod@npm:3.24.1" checksum: 10/54e25956495dec22acb9399c168c6ba657ff279801a7fcd0530c414d867f1dcca279335e160af9b138dd70c332e17d548be4bc4d2f7eaf627dead50d914fec27 languageName: node linkType: hard + +"zod@npm:^3.25.0 || ^4.0.0": + version: 4.4.3 + resolution: "zod@npm:4.4.3" + checksum: 10/804b9a42aa8f35f2b3c5a8dff906291cb749115f83ee2afe3576d70b5b5c53c965365c7f4967690647a9c54af9838ff232a85ff9577a0a36c44b68bc6cdefe36 + languageName: node + linkType: hard