Skip to content

Commit f0c4c12

Browse files
lulusirclaude
andauthored
Feat/acp v1 (#4741)
* feat: add comment * feat: support session * feat: rm useless coment * fix: compile error * fix: fix the di error,ant the chat pannel hide unnecessary buttons in agnet mode * feat: support image content * fix: ci problem * feat: refactor the AcpCliBackService and remove the dependency on AcpTerminalHandler * feat: use pty instead of terminal。fix history restoration errors * chore: update yarn lock * feat: add node path * fix: dep problem of ci * fix: ci * feat: adjust the startup logic of the chat panel * fix: init fail * feat: disable cache of session list * feat: change agentConfig to be passed from the front end * fix: not connected error when initalizing acp * fix: chat history error * feat: add inline chat * feat: refactor ACP components with contribution point pattern - Restore original components to pre-ACP state (ChatHistory, ChatMentionInput, mention-input) - Create ACP-specific components in acp/components/ directory - AcpChatMentionInput: recursive workspace file loading (limit 50) - AcpChatHistory: no delete button (server-managed sessions) - Register ACP components via IChatAgentViewService contribution point - Dynamic component selection in chat.view.tsx based on supportsAgentMode flag - Add design document: docs/plans/2026-04-07-acp-components-refactor.md Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * style: add disabled styles for chat history list and new button * feat: add disabled prop to AcpChatHistory component Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: subscribe to session loading event in AcpChatViewHeader Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: disable chat input during session loading Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: code review problem * fix: ci * feat: reserve space for existing active sessions to avoid being eliminated by LRU * feat(acp): scope @file and @folder search to agent cwd Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(acp): pass agentCwd to ChatInputWrapperRender from appConfig Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(acp): use agentCwd in folder getHighestLevelItems root comparison Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(acp): support configuring enabled mention types via IChatRenderRegistry Secondary developers can now call registry.registerEnabledMentionTypes(['file', 'folder']) in their registerChatRender contribution to control which @mention types are shown. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(acp): disable @code mention in ai-native contribution Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: throw error when acp not ready * feat(acp): support providerDefaultInput for slash command shortcuts Add providerDefaultInput to IChatSlashCommandHandler so that clicking a shortcut can auto-fill the chat input with a default value. The user can then edit and send. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(acp): add providerDefaultInput example for Explain slash command Auto-fills chat input with editor selection when clicking the Explain shortcut. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: compile error * fix(acp): auto-create session when loading fails with Resource not found When an ACP session cannot be loaded (e.g. agent process restarted, session data cleaned up), automatically create a new session instead of showing an error. The provider now re-throws errors to let activateSession handle fallback uniformly. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: add ACP chat multi-workspace support design spec Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: add ACP chat multi-workspace implementation plan Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(acp): add i18n strings for multi-workspace directory selection Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(acp): add pickWorkspaceDir utility for multi-workspace selection Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(acp): use pickWorkspaceDir in ACPSessionProvider for multi-workspace support Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(acp): use pickWorkspaceDir in AcpChatAgent for multi-workspace support Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(acp): return empty string instead of undefined from pickWorkspaceDir The workspaceDir field in AgentProcessConfig expects string, not string | undefined. Return '' for the no-workspace case to match the original behavior. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(acp): cache workspace dir selection and add switch button in header - pickWorkspaceDir now caches the selection, only prompts on first call - Added switchWorkspaceDir() to force re-pick (clears cache) - Added folder icon button in chat header (visible only in multi-root workspace mode) to switch working directory - Added i18n key for switch button tooltip Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(acp): move workspace switch button to AcpChatViewHeader The button was incorrectly added to DefaultChatViewHeader which is not used in ACP mode. Moved to AcpChatViewHeader where the ACP-specific header is rendered. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(acp): read cached workspace dir on each render instead of stale state getCachedWorkspaceDir() is read at render time so the tooltip always reflects the current cached value, even when the cache was updated externally by pickWorkspaceDir via session provider. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(acp): auto-create new session after switching workspace directory The ACP agent process is initialized with a fixed cwd at session creation time. Switching workspace dir only takes effect for new sessions, so we now auto-create a new session when the user picks a different path via the header button. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(acp): fix workspace dir tooltip not updating and improve hover text - Use React state instead of reading cache directly in render, with useEffect to sync when cache is updated externally - Update tooltip to show: "当前路径:xxx,点击切换工作路径(切换会新建会话)" Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(acp): force Popover remount on workspace dir change via key prop The Popover component does not update its overlay content when the title prop changes. Adding a key that includes the current workspace dir forces React to remount the Popover when the path changes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: rm doc * feat(acp): rewrite ACPChatAgentPromptProvider with XML-free prompt format ACP agents like Claude don't need XML-tagged prompts. The provider now strips XML tags from serialized context, uses plain text with --- separator between context and user message, and returns bare userMessage when no context is present. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(acp): add 30s timeout with retry button for ACP initialization When ACP service initialization takes longer than 30 seconds, show a retry button that resets and restarts the entire initialization flow. Includes cancellation of in-flight async work on retry to prevent state race conditions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(ai-native): add ChatWelcomePageRender extension point Allow secondary development to register a custom welcome page that occupies the chat_container, replacing the MessageList until the user sends their first message. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(acp): fallback to default agent when ACP initialization fails Add graceful degradation when ACP service is unavailable: - Add fallbackToLocal() to ChatManagerService, switching session provider to LocalStorageProvider - Add registerFallbackAgent() to ChatProxyService, replacing AcpChatAgent with DefaultChatAgent on failure - Wire up OpenAI-compatible API in AcpCliBackService for non-ACP request handling - Add no-op metadata setter to DefaultChatAgent for compatibility - Remove dead ACPErrorView code from AcpChatViewWrapper Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(ai-native): add chat input contribution point and @ mention trigger button - Introduce ChatInputRegistry for priority-based chat input component resolution - Register default inputs: ACP (200), MCP (100), Basic (50) - Add @ mention trigger button to ACP footer row alongside send button - Wire built-in mention-trigger button ID to MentionInput.handleTitleClick Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(ai-native): add ChatViewRegistry and ChatHistoryRegistry contribution points Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(acp): wire chat view and history contribution points - Add DynamicChatViewWrapper to resolve active chat view at render time - Register ACP views with priority 200 when supportsAgentMode is enabled - Register default views with priority 50 as fallback - Update registerComponent to use dynamic wrapper - Add ChatHistoryACP and ChatMentionInputACP copies Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(ai-native): consume ChatHistoryRegistry in ACP view and unify DI tokens - Wire ChatHistoryRegistry in chat.view.acp.tsx: replace chatRenderRegistry.chatHistoryRender with chatHistoryRegistry.getActiveChatHistory() for dynamic selection - Move ChatViewRegistryToken and ChatHistoryRegistryToken to @opensumi/ide-core-common to avoid duplicate Symbol() creating different tokens - Remove AcpChatViewWrapper from default chat.view.tsx (should render directly without ACP gate) - Use `when:` callbacks instead of static `if` blocks in contribution registration for runtime-evaluated conditions Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * revert: remove comment-only changes in chat module files Restore 6 files that only had JSDoc comments added on top, back to their main branch versions: apply.service.ts, chat-agent.view.service.ts, chat-edit-resource.ts, chat-multi-diff-source.ts, chat.api.service.ts, chat.feature.registry.ts Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * revert: remove top-level JSDoc comments and restore chat.view.tsx to main Remove added JSDoc comment blocks from chat-model.ts, chat-proxy.service.ts, chat.internal.service.ts, chat.render.registry.ts; restore eslint-disable in chat-model.ts; restore chat.view.tsx to main branch version. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(ai-native): restore core files to main and use .acp.ts subclass pattern Restore chat-manager.service.ts, chat.internal.service.ts, chat-proxy.service.ts, and ChatMentionInput.tsx to main branch state. ACP-specific logic is isolated in .acp.ts subclasses (AcpChatManagerService, AcpChatInternalService, AcpChatProxyService). DI registration uses factory pattern to conditionally inject base or ACP subclass based on supportsAgentMode capability. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat: add acp mode * feat(ai-native): add AcpChatInput component and register in ACP mode Adds a dedicated ChatInput component for ACP mode, registered via ChatInputRegistry with priority 150 and a when() condition gated by supportsAgentMode. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(ai-native): extract ACP-specific components into browser/components/acp Move ACP-specific changes out of shared components into separate files under browser/components/acp/ to keep main components clean. - Create acp/MentionInput.tsx with mode selector and permission dialog - Create acp/ChatReply.tsx with optional chaining fix for sessionModel - Create acp/chat-history.module.less with ACP-specific disabled/loading styles - Create acp/mention-input.module.less with ACP-specific button/mode styles - Create acp/types.ts with ACPFooterConfig and ModeOption interfaces - Fix ACP input placeholder to "message claude-agent-acp @to include context, / for command" - Move context preview into footer row alongside send button - Remove duplicate mention-trigger button (context preview serves as trigger) - Fix useCallback dependency arrays in ChatHistory.acp.tsx - Restore main components to original state (ChatHistory, ChatReply, mention-input) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(ai-native): inject slash command text into editor in ACP mode Pass slash command theme through MentionInput's new slashCommand prop, which inserts the text directly into the contentEditable editor instead of rendering it as a separate badge above the input. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(ai-native): add ACP footer buttons (MCP, Rules, slash command) Add AcpMCPFooterButton, AcpRulesFooterButton, and AcpSlashCommandFooter components with chat-input-footer registry for extensible footer items. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(ai-native): move footer registration from useEffect to Contribution Move slash command footer item registration from AcpChatMentionInput's useEffect into AcpFooterContribution (ClientAppContribution pattern), and remove MCP/Rules footer items that are no longer needed. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(ai-native): insert slash commands as tags at cursor position - Replace prepending slash text with cursor-position insertion - Render slash commands as styled tags (non-editable span) instead of plain text - Reuse built-in "/" mention panel instead of separate footer dropdown - Add opensumi-chat-input-open-slash-panel event for external triggers Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(ai-native): strip space from slash command display and use data-attribute selector - Remove space in nameWithSlash getter: "/ Explain" -> "/Explain" - Fix slash tag serialization by using span[data-command] selector instead of CSS class selector which failed due to CSS modules composes behavior - Remove debug console.log Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: delete the incorrect tests * fix(ai-native): deduplicate availableCommands by name and remove debug logs Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(ai-native): restore deleted test files from main Re-add 8 test files covering chat agent service, chat model, diff computer, multi-line decoration, tree-sitter, and MCP servers. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(ai-native): sync command state when selecting slash command from dropdown When a slash command is selected from the MentionInput dropdown, only a visual DOM tag was inserted but the parent's command state was never updated. This caused AcpChatAgent to receive an empty command string, skipping the custom invoke handler. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * chore(ai-native): remove unnecessary debug logs from ACP module Removed 10 verbose console.log/logger.log debug statements and 3 noisy dispose lifecycle logs across the ACP module. Error and warn level logs are preserved. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(ai-native): resolve TypeScript errors in ACP session provider and mention input - Remove dead code (unreachable return) and add null-safe fallbacks in acp-session-provider.ts - Remove duplicate `localize` import in ChatMentionInput.acp.tsx Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * chore(ai-native): comment out verbose debug log in CLI client Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(ai-native): fix slash command name assertion to not expect trailing space Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(ai-native): prevent slash command duplication in input and chat list - Remove the slashCommand useEffect in MentionInput that duplicated the slash tag alongside the custom event insertion path - Guard CodeBlockWrapperInput to skip rendering the command prop tag when it was already extracted from the message text Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore(ai-native): remove unnecessary debug logs from ACP CLI client service Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test(ai-native): add unit tests for ACP CLI back service Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test(ai-native): fix ACP browser unit tests and make initStorage lazy - Add @opensumi/di mock to prevent DI container errors in unit tests - Make AcpPermissionHandler.initStorage() lazy (ensureInitialized pattern) - Fix auditLog assertion to match actual timestamp format - Fix getSmartTitle assertion for undefined kind Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * chore(ai-native): remove unused import and empty CSS comments Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(ai-native): fix MentionItem[] type mismatch in folder search The else branch in getItems was using a shadowed const folders (string[]) instead of the outer MentionItem[] variable, causing a type error when passed to expandFolderPaths which expects string[]. Renamed the intermediate variable to folderPaths and assigned the expanded result to folders. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(ai-native): fix button visibility logic and add missing test import ChatReply: when condition was inverted — button was always rendered and then overwritten. Now properly checks !when or when matches before rendering. Also adds missing AgentProcessConfig import in test. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(ai-native): add unit tests for ACP node service files Add 242 tests across 8 test suites covering: - acp-cli-client.service.ts (NDJSON transport, JSON-RPC) - acp-cli-process-manager.ts (process lifecycle) - acp-permission-caller.service.ts (permission routing, option sorting) - acp-agent.service.ts (session management, notifications) - agent-request.handler.ts (request routing delegation) - file-system.handler.ts (file ops, workspace sandboxing) - terminal.handler.ts (PTY terminal lifecycle) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(ai-native): pass messageId to deferred chat component in ChatReply The async branch of getChatComponentDeferred omitted messageId, causing inconsistent props compared to the synchronous path. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(ai-native): add unit tests for CliAgentProcessManager Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(ai-native): fix lint errors in acp-cli-back test and add test cases - Remove unused imports (AcpAgentServiceToken, SimpleMessage) - Remove unused mockStream variable - Fix property shorthand lint error - Add tests for history, images, error handling, and content conversion Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat: update build-windows runner --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e6c1dab commit f0c4c12

109 files changed

Lines changed: 19953 additions & 116 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/check.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ jobs:
5454
yarn run check:dep
5555
5656
build-windows:
57-
runs-on: windows-2019
57+
runs-on: windows-2025
5858
steps:
5959
- uses: actions/checkout@v4
6060
- name: Use Node.js 20.x

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,9 @@
4949
"rebuild:node": "sumi rebuild",
5050
"start": "yarn run rebuild:node && cross-env HOST=127.0.0.1 WS_PATH=ws://127.0.0.1:8000 NODE_ENV=development tsx ./scripts/start",
5151
"start:e2e": "yarn start --script=start:e2e",
52-
"start:electron": "cross-env NODE_ENV=development tsx ./scripts/start-electron",
53-
"start:lite": "cross-env NODE_ENV=development tsx ./scripts/start --script=start:lite",
52+
"start:electron": "cross-env NODE_ENV=development tsx ./scripts/start-electron",
53+
"start:lite": "cross-env NODE_ENV=development tsx ./scripts/start --script=start:lite",
54+
"start:client": "cross-env HOST=127.0.0.1 WS_PATH=ws://127.0.0.1:7001 NODE_ENV=development tsx ./scripts/start --script=start:client",
5455
"start:pty-service": "KTLOG_SHOW_DEBUG=1 npx tsx packages/terminal-next/src/node/pty.proxy.remote.exec.ts",
5556
"start:remote": "yarn run rebuild:node && cross-env NODE_ENV=development tsx ./scripts/start",
5657
"test": "node --expose-gc ./node_modules/.bin/jest --forceExit --detectOpenHandles",
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { AcpPermissionRpcService } from '../../../lib/browser/acp/acp-permission-rpc.service';
2+
import { AcpPermissionBridgeService } from '../../../lib/browser/acp/permission-bridge.service';
3+
4+
// Mock dependencies
5+
const mockBridgeService = {
6+
showPermissionDialog: jest.fn(),
7+
handleUserDecision: jest.fn(),
8+
handleDialogClose: jest.fn(),
9+
cancelRequest: jest.fn(),
10+
onDidRequestPermission: jest.fn(),
11+
onDidReceivePermissionResult: jest.fn(),
12+
getActiveDialogCount: jest.fn(),
13+
getActiveDialogs: jest.fn(),
14+
};
15+
16+
const mockLogger = {
17+
log: jest.fn(),
18+
error: jest.fn(),
19+
debug: jest.fn(),
20+
verbose: jest.fn(),
21+
warn: jest.fn(),
22+
};
23+
24+
describe('AcpPermissionRpcService', () => {
25+
let service: AcpPermissionRpcService;
26+
27+
beforeEach(() => {
28+
jest.clearAllMocks();
29+
30+
service = new AcpPermissionRpcService();
31+
Object.defineProperty(service, 'permissionBridgeService', { value: mockBridgeService, writable: true });
32+
Object.defineProperty(service, 'logger', { value: mockLogger, writable: true });
33+
});
34+
35+
describe('$showPermissionDialog()', () => {
36+
it('should forward params to bridge service and return decision', async () => {
37+
const params = {
38+
requestId: 'req-001',
39+
title: 'Test title',
40+
kind: 'write',
41+
content: 'Test content',
42+
locations: [{ path: '/workspace/file.txt', line: 10 }],
43+
command: undefined,
44+
options: [{ optionId: 'opt-1', name: 'Allow', kind: 'allow_once' }],
45+
timeout: 30000,
46+
};
47+
48+
mockBridgeService.showPermissionDialog.mockResolvedValue({
49+
type: 'allow',
50+
optionId: 'opt-1',
51+
always: false,
52+
});
53+
54+
const result = await service.$showPermissionDialog(params);
55+
56+
expect(mockBridgeService.showPermissionDialog).toHaveBeenCalledWith({
57+
requestId: 'req-001',
58+
title: 'Test title',
59+
kind: 'write',
60+
content: 'Test content',
61+
locations: [{ path: '/workspace/file.txt', line: 10 }],
62+
command: undefined,
63+
options: [{ optionId: 'opt-1', name: 'Allow', kind: 'allow_once' }],
64+
timeout: 30000,
65+
});
66+
expect(result).toEqual({ type: 'allow', optionId: 'opt-1', always: false });
67+
});
68+
69+
it('should return cancelled on error', async () => {
70+
const params = {
71+
requestId: 'req-002',
72+
title: 'Test title',
73+
kind: 'write',
74+
content: 'Test content',
75+
options: [],
76+
timeout: 30000,
77+
};
78+
79+
mockBridgeService.showPermissionDialog.mockRejectedValue(new Error('Bridge error'));
80+
81+
const result = await service.$showPermissionDialog(params);
82+
83+
expect(result).toEqual({ type: 'cancelled' });
84+
});
85+
});
86+
87+
describe('$cancelRequest()', () => {
88+
it('should forward cancel request to bridge service', async () => {
89+
await service.$cancelRequest('req-001');
90+
91+
expect(mockBridgeService.cancelRequest).toHaveBeenCalledWith('req-001');
92+
});
93+
});
94+
});
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
import { Emitter } from '@opensumi/ide-core-common';
2+
3+
import {
4+
AcpPermissionBridgeService,
5+
ShowPermissionDialogParams,
6+
} from '../../../lib/browser/acp/permission-bridge.service';
7+
8+
// Mock @opensumi/di to make decorators no-ops
9+
jest.mock('@opensumi/di', () => {
10+
const actual = jest.requireActual('@opensumi/di');
11+
const noopDecorator = () => () => {};
12+
return {
13+
...actual,
14+
Injectable: () => (cls: any) => cls,
15+
Autowired: noopDecorator,
16+
Inject: noopDecorator,
17+
Optional: noopDecorator,
18+
};
19+
});
20+
21+
// Mock dependencies
22+
const mockLogger = {
23+
log: jest.fn(),
24+
error: jest.fn(),
25+
debug: jest.fn(),
26+
verbose: jest.fn(),
27+
warn: jest.fn(),
28+
};
29+
30+
const mockMainLayoutService = {};
31+
32+
describe('AcpPermissionBridgeService', () => {
33+
let service: AcpPermissionBridgeService;
34+
35+
const mockParams: ShowPermissionDialogParams = {
36+
requestId: 'req-001',
37+
title: 'Test permission',
38+
kind: 'write',
39+
content: 'Edit file.txt',
40+
locations: [{ path: '/workspace/file.txt' }],
41+
options: [
42+
{ optionId: 'allow_once', name: 'Allow Once', kind: 'allow_once' },
43+
{ optionId: 'reject_once', name: 'Reject', kind: 'reject_once' },
44+
],
45+
timeout: 5000,
46+
};
47+
48+
beforeEach(() => {
49+
jest.useFakeTimers();
50+
jest.clearAllMocks();
51+
52+
service = new AcpPermissionBridgeService();
53+
Object.defineProperty(service, 'logger', { value: mockLogger, writable: true });
54+
Object.defineProperty(service, 'mainLayoutService', { value: mockMainLayoutService, writable: true });
55+
});
56+
57+
afterEach(() => {
58+
jest.useRealTimers();
59+
});
60+
61+
describe('showPermissionDialog()', () => {
62+
it('should return cancelled if dialog already exists for requestId', async () => {
63+
const promise1 = service.showPermissionDialog(mockParams);
64+
const promise2 = service.showPermissionDialog(mockParams);
65+
66+
expect(await promise2).toEqual({ type: 'cancelled' });
67+
});
68+
69+
it('should fire onDidRequestPermission event', async () => {
70+
const receivedParams: ShowPermissionDialogParams[] = [];
71+
service.onDidRequestPermission((params) => receivedParams.push(params));
72+
73+
service.showPermissionDialog(mockParams);
74+
75+
expect(receivedParams).toHaveLength(1);
76+
expect(receivedParams[0].requestId).toBe('req-001');
77+
});
78+
79+
it('should resolve with allow when user decides allow_once', async () => {
80+
const promise = service.showPermissionDialog(mockParams);
81+
82+
service.handleUserDecision('req-001', 'allow_once', 'allow_once');
83+
84+
const result = await promise;
85+
expect(result).toEqual({
86+
type: 'allow',
87+
optionId: 'allow_once',
88+
always: false,
89+
});
90+
});
91+
92+
it('should resolve with reject when user decides reject_once', async () => {
93+
const promise = service.showPermissionDialog(mockParams);
94+
95+
service.handleUserDecision('req-001', 'reject_once', 'reject_once');
96+
97+
const result = await promise;
98+
expect(result).toEqual({
99+
type: 'reject',
100+
optionId: 'reject_once',
101+
always: false,
102+
});
103+
});
104+
105+
it('should resolve with allow and always=true for allow_always', async () => {
106+
const promise = service.showPermissionDialog(mockParams);
107+
108+
service.handleUserDecision('req-001', 'allow_always', 'allow_always');
109+
110+
const result = await promise;
111+
expect(result.type).toBe('allow');
112+
expect(result.always).toBe(true);
113+
});
114+
115+
it('should fire onDidReceivePermissionResult on user decision', async () => {
116+
const results: any[] = [];
117+
service.onDidReceivePermissionResult((result) => results.push(result));
118+
119+
const promise = service.showPermissionDialog(mockParams);
120+
service.handleUserDecision('req-001', 'allow_once', 'allow_once');
121+
await promise;
122+
123+
expect(results).toHaveLength(1);
124+
expect(results[0].requestId).toBe('req-001');
125+
expect(results[0].decision.type).toBe('allow');
126+
});
127+
});
128+
129+
describe('handleDialogClose()', () => {
130+
it('should resolve with timeout when dialog closes', async () => {
131+
const promise = service.showPermissionDialog(mockParams);
132+
133+
service.handleDialogClose('req-001');
134+
135+
const result = await promise;
136+
expect(result).toEqual({ type: 'timeout' });
137+
});
138+
139+
it('should do nothing when no pending decision', () => {
140+
// Should not throw
141+
service.handleDialogClose('non-existent-id');
142+
});
143+
144+
it('should fire onDidReceivePermissionResult with timeout decision', async () => {
145+
const results: any[] = [];
146+
service.onDidReceivePermissionResult((result) => results.push(result));
147+
148+
const promise = service.showPermissionDialog(mockParams);
149+
service.handleDialogClose('req-001');
150+
await promise;
151+
152+
expect(results).toHaveLength(1);
153+
expect(results[0].decision.type).toBe('timeout');
154+
});
155+
});
156+
157+
describe('cancelRequest()', () => {
158+
it('should resolve with timeout (same as handleDialogClose)', async () => {
159+
const promise = service.showPermissionDialog(mockParams);
160+
161+
service.cancelRequest('req-001');
162+
163+
const result = await promise;
164+
expect(result).toEqual({ type: 'timeout' });
165+
});
166+
});
167+
168+
describe('getActiveDialogCount()', () => {
169+
it('should return 0 initially', () => {
170+
expect(service.getActiveDialogCount()).toBe(0);
171+
});
172+
173+
it('should return correct count with active dialogs', () => {
174+
service.showPermissionDialog(mockParams);
175+
expect(service.getActiveDialogCount()).toBe(1);
176+
177+
service.handleUserDecision('req-001', 'allow_once', 'allow_once');
178+
expect(service.getActiveDialogCount()).toBe(0);
179+
});
180+
});
181+
182+
describe('getActiveDialogs()', () => {
183+
it('should return empty array initially', () => {
184+
expect(service.getActiveDialogs()).toEqual([]);
185+
});
186+
187+
it('should return active dialog props', () => {
188+
service.showPermissionDialog(mockParams);
189+
190+
const dialogs = service.getActiveDialogs();
191+
expect(dialogs).toHaveLength(1);
192+
expect(dialogs[0].requestId).toBe('req-001');
193+
expect(dialogs[0].visible).toBe(true);
194+
});
195+
});
196+
});

0 commit comments

Comments
 (0)