diff --git a/.eslintignore b/.eslintignore index 79f12dd09a..bfc9d5b3b1 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,4 +1,5 @@ node_modules +tmp/** packages/process/scripts tools/electron/scripts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0886e405c9..aa54cc48d0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,7 +47,13 @@ jobs: restore-keys: | ${{ runner.os }}-yarn- + - name: Install libsecret-1 + if: ${{ runner.os == 'Linux' }} + run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev + - name: Install + env: + ELECTRON_SKIP_BINARY_DOWNLOAD: 1 run: | yarn install --immutable yarn constraints diff --git a/.gitignore b/.gitignore index 26c7cf18ce..5b5a323bc2 100644 --- a/.gitignore +++ b/.gitignore @@ -99,4 +99,10 @@ tools/workspace .ipynb_checkpoints *.tsbuildinfo -.env \ No newline at end of file +.env + +# Claude Code +.claude/ +.claudebak + +.understand-anything/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..113987352c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,110 @@ +# OpenSumi Core Agent Guide + +This file is the root-level guide for agents working in the OpenSumi core repository. Keep it stable, project-wide, and useful for long-term maintenance. Short-term feature context belongs in the appendix or in task-specific notes. + +## Project Overview + +- This repository is the `@opensumi/core` TypeScript monorepo for OpenSumi. +- Package management uses Yarn 4.4.1 with `nodeLinker: node-modules`; the required Node version is `>=18.12.0`. +- Workspaces are `packages/*`, `tools/dev-tool`, `tools/playwright`, and `tools/cli-engine`. +- Common entrypoints: + - `yarn install` installs dependencies. + - `yarn run init` performs the full clean/build initialization. + - `yarn start` starts the normal web IDE. + - `yarn start:e2e` starts the e2e profile. + - `yarn start:electron` starts the Electron profile. + - `yarn test` runs Jest with the repository defaults. + - `yarn test:ui` runs Playwright UI tests. +- Before starting local services, check common ports when relevant: + +```bash +lsof -nP -iTCP:8080 -sTCP:LISTEN || true +lsof -nP -iTCP:8000 -sTCP:LISTEN || true +``` + +## Code Navigation + +- Use CodeGraph for structural questions: symbol definitions, signatures, callers, callees, dependency impact, and unfamiliar module surveys. +- Use `rg` for literal text: log messages, comments, string constants, file names, and exact code fragments. +- Do not grep first when looking for a symbol by name if CodeGraph is available. +- When changing shared behavior, inspect both implementation and nearby tests before editing. +- For broad areas, prefer `codegraph_context` or `codegraph_explore` over a chain of narrow searches. + +## Architecture Boundaries + +- Respect the `browser`, `node`, and `common` split: + - `browser` code must not import `node` runtime modules. + - `node` code must not import `browser` runtime modules. + - `common` code must not depend on browser-only or node-only runtime modules. +- Preserve package boundaries under `packages/*`. Prefer public package exports and existing local APIs over deep imports unless the surrounding code already does so. +- Follow OpenSumi's contribution and dependency-injection patterns. Prefer existing contribution registries, services, symbols, and lifecycle hooks over ad hoc wiring. +- When changing public types, settings, commands, contribution contracts, or exported package APIs, check downstream references across the monorepo and update tests at the contract boundary. +- Keep UI changes consistent with existing OpenSumi components, layout services, tabbar behavior, and style conventions. + +## Development Workflow + +- Start with `git status --short`. This repository often has active local changes. +- Never revert or overwrite user changes unless explicitly requested. +- Keep edits narrowly scoped to the requested behavior. Avoid unrelated refactors, formatting churn, and metadata changes. +- Use `apply_patch` for manual tracked-file edits. +- Prefer repository scripts and local helper APIs over introducing new tooling. +- For structured data, use structured parsers or existing helpers instead of ad hoc string manipulation. +- Before finishing code changes, run `git diff --check` when practical. + +## Build and Test Matrix + +- TypeScript or shared API changes: choose the narrowest affected TypeScript reference or package-level typecheck that covers the files you touched. For cross-package contracts, use the relevant reference under `configs/ts/references/`. + +```bash +yarn tsc --build --pretty false +``` + +- Package-specific build when touching package build output or package-level contracts: run the build for the workspace package you changed. + +```bash +yarn workspace build +``` + +- Focused Jest tests are usually preferred over full-suite runs during iteration: + +```bash +yarn test --runInBand +yarn jest --runInBand +``` + +- Use `--selectProjects jsdom` or `--runTestsByPath` for browser/jsdom tests when the Jest project selection matters. +- For layout, startup, browser integration, or real DOM behavior, validate with the running IDE or Playwright/CDP in addition to unit tests. +- For UI test coverage, use: + +```bash +yarn test:ui +yarn test:ui-headful +yarn test:ui-report +``` + +- For BDD scenarios, read `test/bdd/README.md` first and run only the relevant scenario set unless the user asks for the full suite. +- If a full verification is too expensive or blocked, report the focused checks that ran and the remaining risk. + +## Review Expectations + +- For code reviews, lead with correctness issues, behavioral regressions, contract risks, and missing tests. +- Prefer concrete file/line references and describe the user-visible or integration impact. +- For cross-package changes, check API compatibility, import boundaries, and whether dependent packages need updated tests. +- For UI/layout reviews, check real runtime behavior, not just component snapshots. +- For protocol, MCP, WebMCP, or extension-facing changes, check naming stability, capability gating, backwards compatibility, and log/token safety. + +## Current Focus Appendix + +This appendix is for stable guidance that is still too area-specific for the main sections. Do not store short-term feature notes, temporary tool names, sprint priorities, or one-off validation shortcuts in the root `AGENTS.md`. Put those details in a nearby package-level `AGENTS.md`, `test/bdd/README.md`, protocol documentation, or task-specific notes instead. + +### Protocol, MCP, and Extension-Facing Work + +- Treat protocol types, contribution registries, BDD scenarios, and nearby package documentation as the source of truth for current capability names and behavior. +- Keep externally visible names stable unless the task explicitly changes the public contract. When changing them, update browser exposure, MCP exposure, tests, and documentation together. +- For security-sensitive integration points, verify capability gating, backwards compatibility, and log/token redaction. + +### Layout and Runtime Validation + +- For layout, startup, browser integration, or real DOM behavior, validate the relevant runtime profile rather than relying only on component snapshots. +- Choose the launch profile that actually enables the feature under test. If profiles differ, document which profile you used and what risk remains. +- For browser checks, wait until the IDE is fully loaded before judging layout or behavior. diff --git a/jest.setup.node.js b/jest.setup.node.js index e99d571b2b..50a909d792 100644 --- a/jest.setup.node.js +++ b/jest.setup.node.js @@ -1,7 +1,16 @@ require('./jest.setup.base'); +const nodeCrypto = require('crypto'); + const { JSDOM, ResourceLoader } = require('jsdom'); +if (!global.crypto || !global.crypto.getRandomValues || !global.crypto.subtle) { + Object.defineProperty(global, 'crypto', { + value: nodeCrypto.webcrypto, + configurable: true, + }); +} + const resourceLoader = new ResourceLoader({ strictSSL: false, userAgent: `Mozilla/5.0 (${ diff --git a/packages/ai-native/__test__/browser/acp-bdd-runtime-fixtures.test.ts b/packages/ai-native/__test__/browser/acp-bdd-runtime-fixtures.test.ts new file mode 100644 index 0000000000..4860e35725 --- /dev/null +++ b/packages/ai-native/__test__/browser/acp-bdd-runtime-fixtures.test.ts @@ -0,0 +1,33 @@ +import { + ACP_BDD_BACKEND_READY_FAILURE_QUERY_PARAM, + ACP_BDD_BACKEND_READY_FAILURE_QUERY_VALUE, + canUseAcpBddRuntimeFixture, + shouldForceAcpBackendReadinessFailure, +} from '../../src/browser/acp/acp-bdd-runtime-fixtures'; + +describe('ACP BDD runtime fixtures', () => { + it('only enables runtime fixture switches on loopback hosts', () => { + expect(canUseAcpBddRuntimeFixture('localhost')).toBe(true); + expect(canUseAcpBddRuntimeFixture('127.0.0.1')).toBe(true); + expect(canUseAcpBddRuntimeFixture('::1')).toBe(true); + expect(canUseAcpBddRuntimeFixture('[::1]')).toBe(true); + expect(canUseAcpBddRuntimeFixture('example.com')).toBe(false); + expect(canUseAcpBddRuntimeFixture(undefined)).toBe(false); + }); + + it('requires the aiNative test mode query and explicit readiness failure value', () => { + const enabledSearch = `?aiNative=true&${ACP_BDD_BACKEND_READY_FAILURE_QUERY_PARAM}=${ACP_BDD_BACKEND_READY_FAILURE_QUERY_VALUE}`; + + expect(shouldForceAcpBackendReadinessFailure(enabledSearch, 'localhost')).toBe(true); + expect(shouldForceAcpBackendReadinessFailure(enabledSearch, 'example.com')).toBe(false); + expect( + shouldForceAcpBackendReadinessFailure(`?${ACP_BDD_BACKEND_READY_FAILURE_QUERY_PARAM}=reject`, 'localhost'), + ).toBe(false); + expect( + shouldForceAcpBackendReadinessFailure( + `?aiNative=true&${ACP_BDD_BACKEND_READY_FAILURE_QUERY_PARAM}=false`, + 'localhost', + ), + ).toBe(false); + }); +}); diff --git a/packages/ai-native/__test__/browser/acp-chat-history.test.tsx b/packages/ai-native/__test__/browser/acp-chat-history.test.tsx new file mode 100644 index 0000000000..e9854e0952 --- /dev/null +++ b/packages/ai-native/__test__/browser/acp-chat-history.test.tsx @@ -0,0 +1,439 @@ +import * as React from 'react'; +import { Root, createRoot } from 'react-dom/client'; +import { Simulate, act } from 'react-dom/test-utils'; + +import AcpChatHistory, { IChatHistoryItem, IChatHistoryProps } from '../../src/browser/acp/components/AcpChatHistory'; + +jest.mock('@opensumi/ide-components', () => { + const React = require('react'); + return { + Icon: ({ 'data-testid': testId, iconClass, animate }: any) => + React.createElement('span', { + 'data-testid': testId, + 'data-icon-class': iconClass, + 'data-animate': animate, + }), + Input: ({ value, defaultValue, onChange, onPressEnter, onBlur, className, placeholder }: any) => + React.createElement('input', { + className, + placeholder, + value, + defaultValue, + onChange, + onBlur, + onKeyDown: (event: KeyboardEvent) => { + if (event.key === 'Enter') { + onPressEnter?.(event); + } + }, + }), + Loading: () => React.createElement('span', { 'data-testid': 'acp-chat-history-loading' }), + Popover: ({ children, content, title }: any) => + React.createElement( + 'div', + { 'data-testid': 'mock-popover', title }, + children, + React.createElement('div', { 'data-testid': 'mock-popover-content' }, content), + ), + PopoverPosition: { + bottomRight: 'bottomRight', + top: 'top', + }, + PopoverTriggerType: { + click: 'click', + }, + getIcon: (name: string) => `icon-${name}`, + }; +}); + +jest.mock('@opensumi/ide-core-browser', () => ({ + localize: (_key: string, defaultValue?: string) => defaultValue || _key, +})); + +jest.mock('@opensumi/ide-core-browser/lib/components/ai-native', () => ({ + EnhanceIcon: ({ className, onClick, ariaLabel }: any) => + require('react').createElement('span', { + 'aria-label': ariaLabel, + className, + onClick, + }), +})); + +jest.mock('../../src/browser/components/acp/chat-history.module.less', () => ({ + chat_history_header: 'chat_history_header', + chat_history_header_title: 'chat_history_header_title', + chat_history_header_actions: 'chat_history_header_actions', + chat_history_header_actions_history: 'chat_history_header_actions_history', + chat_history_button_wrapper: 'chat_history_button_wrapper', + pending_permission_badge: 'pending_permission_badge', + pending_permission_badge_inline: 'pending_permission_badge_inline', + chat_history_header_actions_new: 'chat_history_header_actions_new', + chat_history_header_actions_new_disabled: 'chat_history_header_actions_new_disabled', + chat_history_header_actions_mcp: 'chat_history_header_actions_mcp', + chat_history_header_inline_actions: 'chat_history_header_inline_actions', + chat_history_header_actions_collapse: 'chat_history_header_actions_collapse', + chat_history_header_bar: 'chat_history_header_bar', + chat_history_inline: 'chat_history_inline', + chat_history_inline_content: 'chat_history_inline_content', + chat_history_inline_list: 'chat_history_inline_list', + chat_history_search: 'chat_history_search', + chat_history_list: 'chat_history_list', + chat_history_list_disabled: 'chat_history_list_disabled', + chat_history_loading: 'chat_history_loading', + chat_history_item: 'chat_history_item', + chat_history_item_selected: 'chat_history_item_selected', + chat_history_item_pending: 'chat_history_item_pending', + chat_history_item_content: 'chat_history_item_content', + chat_history_item_pending_icon: 'chat_history_item_pending_icon', + chat_history_item_title: 'chat_history_item_title', +})); + +describe('AcpChatHistory BDD', () => { + let container: HTMLDivElement; + let root: Root; + + const baseHistoryList: IChatHistoryItem[] = [ + { + id: 'acp:oldest', + title: 'Oldest Session', + createdAt: 1000, + loading: false, + threadStatus: 'idle', + }, + { + id: 'acp:middle', + title: 'Middle Session', + createdAt: 2000, + loading: false, + threadStatus: 'awaiting_prompt', + }, + { + id: 'acp:current', + title: 'New Session', + createdAt: 3000, + loading: false, + threadStatus: 'idle', + }, + ]; + + const defaultProps: IChatHistoryProps = { + title: 'Chat History', + historyList: baseHistoryList, + currentId: 'acp:current', + onNewChat: jest.fn(), + onHistoryItemSelect: jest.fn(), + onHistoryItemChange: jest.fn(), + }; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + jest.clearAllMocks(); + }); + + function renderHistory(props: Partial = {}) { + const mergedProps = { ...defaultProps, ...props }; + act(() => { + root.render(React.createElement(AcpChatHistory, mergedProps)); + }); + return mergedProps; + } + + function getRenderedItemIds(): string[] { + return Array.from(container.querySelectorAll('[data-testid^="chat-history-item-"]')).map((item) => + item.getAttribute('data-testid')!.replace('chat-history-item-', ''), + ); + } + + function getHistoryItem(id: string): HTMLElement { + const item = container.querySelector(`[data-testid="chat-history-item-${id}"]`); + expect(item).not.toBeNull(); + return item as HTMLElement; + } + + function changeSearchValue(value: string): void { + const input = container.querySelector('input[placeholder="aiNative.operate.chatHistory.searchPlaceholder"]'); + expect(input).not.toBeNull(); + act(() => { + Simulate.change(input as HTMLInputElement, { target: { value } } as any); + }); + } + + it('Given manager order puts the current empty session last, when the popover renders, then the current session appears first', () => { + renderHistory(); + + expect(container.querySelector('[data-testid="acp-chat-history-button"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="acp-chat-history-popover"]')).not.toBeNull(); + expect(getRenderedItemIds()).toEqual(['acp:current', 'acp:middle', 'acp:oldest']); + expect(getHistoryItem('acp:current').className).toContain('chat_history_item_selected'); + }); + + it('Given manager order is mixed, when the popover renders, then sessions are ordered by creation time descending', () => { + renderHistory({ + historyList: [ + { + id: 'acp:newest', + title: 'Newest Session', + createdAt: 3000, + loading: false, + }, + { + id: 'acp:oldest', + title: 'Oldest Session', + createdAt: 1000, + loading: false, + }, + { + id: 'acp:middle', + title: 'Middle Session', + createdAt: 2000, + loading: false, + }, + ], + currentId: 'acp:middle', + }); + + expect(getRenderedItemIds()).toEqual(['acp:newest', 'acp:middle', 'acp:oldest']); + }); + + it('Given legacy sessions have no creation time, when the popover renders, then it falls back to reverse manager order', () => { + renderHistory({ + historyList: baseHistoryList.map((item) => ({ + ...item, + createdAt: 0, + })), + }); + + expect(getRenderedItemIds()).toEqual(['acp:current', 'acp:middle', 'acp:oldest']); + }); + + it('Given inline variant, when it renders, then it shows the history list directly without the popover trigger', () => { + renderHistory({ variant: 'inline' }); + + expect(container.querySelector('[data-testid="acp-chat-history-button"]')).toBeNull(); + expect(container.querySelector('[data-testid="acp-chat-history-popover"]')).toBeNull(); + expect(container.querySelector('.chat_history_header_actions')).toBeNull(); + expect(container.querySelector('[data-testid="acp-chat-history-inline"]')).not.toBeNull(); + expect(getRenderedItemIds()).toEqual(['acp:current', 'acp:middle', 'acp:oldest']); + }); + + it('Given inline variant, when the header renders, then the title is replaced by the new-chat action', () => { + const onNewChat = jest.fn(); + renderHistory({ variant: 'inline', title: 'AI Assistant', onNewChat }); + + const title = container.querySelector('.chat_history_header_title') as HTMLElement; + const newChatAction = title.querySelector('.chat_history_header_actions_new') as HTMLElement; + + expect(title.textContent).not.toContain('AI Assistant'); + expect(newChatAction).not.toBeNull(); + + act(() => { + newChatAction.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(onNewChat).toHaveBeenCalledTimes(1); + }); + + it('Given inline variant has an MCP config action, when the header renders, then it appears after the new-chat action and opens MCP config', () => { + const onOpenMCPConfig = jest.fn(); + renderHistory({ variant: 'inline', onOpenMCPConfig, onToggleHistoryCollapsed: jest.fn() }); + + const inlineActions = container.querySelector('.chat_history_header_inline_actions') as HTMLElement; + const actionClasses = Array.from( + inlineActions.querySelectorAll( + '.chat_history_header_actions_collapse, .chat_history_header_actions_new, .chat_history_header_actions_mcp', + ), + ).map((action) => action.className); + const mcpAction = inlineActions.querySelector('.chat_history_header_actions_mcp') as HTMLElement; + + expect(actionClasses).toEqual([ + 'chat_history_header_actions_collapse', + 'chat_history_header_actions_new', + 'chat_history_header_actions_mcp', + ]); + expect(mcpAction).not.toBeNull(); + + act(() => { + mcpAction.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(onOpenMCPConfig).toHaveBeenCalledTimes(1); + }); + + it('Given no MCP config action is provided, when inline history renders, then it does not show the MCP button', () => { + renderHistory({ variant: 'inline' }); + + expect(container.querySelector('.chat_history_header_actions_mcp')).toBeNull(); + }); + + it('Given popover variant has an MCP config action, when it renders, then it does not show the MCP button', () => { + renderHistory({ onOpenMCPConfig: jest.fn() }); + + expect(container.querySelector('.chat_history_header_actions_mcp')).toBeNull(); + }); + + it('Given inline variant supports collapse, when the collapse action is clicked, then it toggles history', () => { + const onToggleHistoryCollapsed = jest.fn(); + renderHistory({ variant: 'inline', onToggleHistoryCollapsed }); + + const collapseAction = container.querySelector('.chat_history_header_actions_collapse') as HTMLElement; + expect(collapseAction).not.toBeNull(); + + act(() => { + collapseAction.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(onToggleHistoryCollapsed).toHaveBeenCalledTimes(1); + }); + + it('Given inline history is collapsed, when it renders, then it keeps header actions and hides the history list', () => { + renderHistory({ variant: 'inline', historyCollapsed: true, onToggleHistoryCollapsed: jest.fn() }); + + expect(container.querySelector('.chat_history_header_actions_new')).not.toBeNull(); + expect(container.querySelector('.chat_history_header_actions_collapse')).not.toBeNull(); + expect(container.querySelector('[data-testid="acp-chat-history-collapsed"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="acp-chat-history-inline"]')).toBeNull(); + }); + + it('Given inline variant mounts, when a visible-change callback is provided, then it refreshes history once', () => { + const onHistoryPopoverVisibleChange = jest.fn(); + + renderHistory({ variant: 'inline', onHistoryPopoverVisibleChange }); + + expect(onHistoryPopoverVisibleChange).toHaveBeenCalledWith(true); + }); + + it('Given a selected history item changes, when the component rerenders, then selection changes without moving the item to the top', () => { + const selected = jest.fn(); + renderHistory({ currentId: 'acp:current', onHistoryItemSelect: selected }); + + act(() => { + getHistoryItem('acp:middle').dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + expect(selected).toHaveBeenCalledWith(expect.objectContaining({ id: 'acp:middle' })); + + renderHistory({ currentId: 'acp:middle', onHistoryItemSelect: selected }); + + expect(getRenderedItemIds()).toEqual(['acp:current', 'acp:middle', 'acp:oldest']); + expect(getHistoryItem('acp:middle').className).toContain('chat_history_item_selected'); + expect(getHistoryItem('acp:current').className).not.toContain('chat_history_item_selected'); + }); + + it('Given a search query, when it matches one title, then only matching history items are shown', () => { + renderHistory(); + + changeSearchValue('Middle'); + + expect(getRenderedItemIds()).toEqual(['acp:middle']); + expect(container.textContent).toContain('Middle Session'); + expect(container.textContent).not.toContain('Oldest Session'); + }); + + it('Given search is active, when a history item is selected, then the search value is cleared', () => { + const selected = jest.fn(); + renderHistory({ onHistoryItemSelect: selected }); + changeSearchValue('Middle'); + + act(() => { + getHistoryItem('acp:middle').dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(selected).toHaveBeenCalledWith(expect.objectContaining({ id: 'acp:middle' })); + expect(getRenderedItemIds()).toEqual(['acp:current', 'acp:middle', 'acp:oldest']); + }); + + it('Given history is disabled, when the user clicks an item or the new-chat action, then no command is fired', () => { + const onNewChat = jest.fn(); + const onHistoryItemSelect = jest.fn(); + renderHistory({ disabled: true, onNewChat, onHistoryItemSelect }); + + act(() => { + getHistoryItem('acp:middle').dispatchEvent(new MouseEvent('click', { bubbles: true })); + container + .querySelector('.chat_history_header_actions_new')! + .dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(onHistoryItemSelect).not.toHaveBeenCalled(); + expect(onNewChat).not.toHaveBeenCalled(); + expect(container.querySelector('[data-testid="acp-chat-history-popover"]')?.className).toContain( + 'chat_history_list_disabled', + ); + }); + + it('Given inline history is disabled, when it renders, then disabled styling still applies to the inline list', () => { + renderHistory({ variant: 'inline', disabled: true }); + + expect(container.querySelector('[data-testid="acp-chat-history-inline"]')?.className).toContain( + 'chat_history_list_disabled', + ); + }); + + it('Given inline history has pending permissions, when it renders, then the inline header keeps the badge visible', () => { + renderHistory({ variant: 'inline', pendingPermissionBadge: 3 }); + + const badge = container.querySelector('[data-testid="acp-pending-permission-badge"]'); + expect(badge).not.toBeNull(); + expect(badge?.className).toContain('pending_permission_badge_inline'); + expect(badge?.textContent).toBe('3'); + }); + + it('Given inline history is loading, when it renders, then the inline list shows the loading state', () => { + renderHistory({ variant: 'inline', historyLoading: true }); + + expect(container.querySelector('[data-testid="acp-chat-history-inline"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="acp-chat-history-loading"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="chat-history-item-acp:current"]')).toBeNull(); + }); + + it('Given a session has pending permission, when it renders, then it shows the pending icon instead of the thread status icon', () => { + renderHistory({ + historyList: [ + ...baseHistoryList, + { + id: 'acp:pending', + title: 'Needs Permission', + createdAt: 4000, + loading: false, + hasPendingPermission: true, + }, + ], + }); + + expect(container.querySelector('[data-testid="acp-permission-pending-acp:pending"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="acp-thread-status-acp:pending-default"]')).toBeNull(); + expect(getHistoryItem('acp:pending').className).toContain('chat_history_item_pending'); + }); + + it('Given the history list is loading, when it renders, then the list items are replaced by a loading state', () => { + renderHistory({ historyLoading: true }); + + expect(container.querySelector('[data-testid="acp-chat-history-loading"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="chat-history-item-acp:current"]')).toBeNull(); + }); + + it('Given more than one hundred history items, when the popover renders, then it keeps only the latest one hundred in reverse display order', () => { + const historyList = Array.from({ length: 101 }, (_, index) => ({ + id: `acp:${index}`, + title: `Session ${index}`, + createdAt: index, + loading: false, + })); + + renderHistory({ historyList, currentId: 'acp:100' }); + + const renderedIds = getRenderedItemIds(); + expect(renderedIds).toHaveLength(100); + expect(renderedIds[0]).toBe('acp:100'); + expect(renderedIds[99]).toBe('acp:1'); + expect(renderedIds).not.toContain('acp:0'); + }); +}); diff --git a/packages/ai-native/__test__/browser/acp-chat-mention-input-ref.test.tsx b/packages/ai-native/__test__/browser/acp-chat-mention-input-ref.test.tsx new file mode 100644 index 0000000000..16b6fd14d4 --- /dev/null +++ b/packages/ai-native/__test__/browser/acp-chat-mention-input-ref.test.tsx @@ -0,0 +1,403 @@ +import * as React from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; +import { act } from 'react-dom/test-utils'; + +let mockMentionInputOnSend: ((content: string, option?: { model: string }) => unknown) | undefined; + +jest.mock('@opensumi/ide-core-browser', () => { + const actual = jest.requireActual('@opensumi/ide-core-browser'); + return { + ...actual, + getSymbolIcon: jest.fn(() => 'symbol-icon'), + useInjectable: jest.fn(), + }; +}); + +jest.mock('@opensumi/ide-core-browser/lib/components', () => ({ + Icon: ({ className }: { className?: string }) => require('react').createElement('span', { className }), + Popover: ({ children, title }: { children: React.ReactNode; title?: string }) => + require('react').createElement('div', { title }, children), + PopoverPosition: { + top: 'top', + }, + getIcon: (name: string) => `icon-${name}`, +})); + +jest.mock('@opensumi/ide-components/lib/image', () => ({ + Image: ({ src }: { src: string }) => require('react').createElement('img', { src }), +})); + +jest.mock('../../src/browser/components/acp/MentionInput', () => ({ + MentionInput: ({ + currentMode, + defaultInput, + expanded, + footerConfig, + onSend, + }: { + currentMode?: string; + defaultInput?: string; + expanded?: boolean; + footerConfig?: { defaultModel?: string; configOptions?: unknown[] }; + onSend?: (content: string, option?: { model: string }) => unknown; + }) => { + mockMentionInputOnSend = onSend; + return require('react').createElement( + 'div', + null, + require('react').createElement('textarea', { + 'data-testid': 'acp-mention-input', + 'data-expanded': expanded ? 'true' : 'false', + 'data-current-mode': currentMode, + 'data-default-model': footerConfig?.defaultModel, + 'data-config-option-count': String(footerConfig?.configOptions?.length ?? 0), + readOnly: true, + value: defaultInput || '', + }), + require('react').createElement( + 'button', + { + 'data-testid': 'acp-mention-send-whitespace', + onClick: () => onSend?.(' \n\t ', { model: 'mock-model' }), + type: 'button', + }, + 'send whitespace', + ), + require('react').createElement( + 'button', + { + 'data-testid': 'acp-mention-send-empty-html', + onClick: () => onSend?.('

  ', { model: 'mock-model' }), + type: 'button', + }, + 'send empty html', + ), + ); + }, +})); + +jest.mock('../../src/browser/components/components.module.less', () => ({ + chat_input_container: 'chat_input_container', + chat_input_container_expanded: 'chat_input_container_expanded', + chat_input_body: 'chat_input_body', + expand_icon: 'expand_icon', + thumbnail_container: 'thumbnail_container', + thumbnail: 'thumbnail', + delete_button: 'delete_button', +})); + +import { AcpChatMentionInput } from '../../src/browser/acp/components/AcpChatMentionInput'; + +function createMockService() { + return { + capabilities: { supportsAgentMode: true }, + workspace: { uri: undefined }, + roots: Promise.resolve([]), + currentEditor: null, + currentUri: undefined, + enabledMentionTypes: undefined, + executeCommand: jest.fn(), + onModeChange: jest.fn(() => ({ dispose: jest.fn() })), + onAvailableCommandsChange: jest.fn(() => ({ dispose: jest.fn() })), + getAvailableCommands: jest.fn(() => []), + getAllSlashCommand: jest.fn(() => []), + getSlashCommandHandler: jest.fn(), + getSlashCommandBySlashName: jest.fn(), + getImageUploadProvider: jest.fn(), + getActiveCodeEditor: jest.fn(), + fromIcon: jest.fn(() => 'codicon codicon-test'), + get: jest.fn(), + error: jest.fn(), + cancelRequest: jest.fn(), + setSessionMode: jest.fn(), + resolveChildren: jest.fn(() => Promise.resolve([])), + asRelativePath: jest.fn(() => Promise.resolve(undefined)), + getFileStat: jest.fn(() => Promise.resolve(undefined)), + find: jest.fn(() => Promise.resolve([])), + getIcon: jest.fn(() => ''), + projectRules: Promise.resolve([]), + }; +} + +describe('AcpChatMentionInput ref contract', () => { + let container: HTMLDivElement; + let consoleErrorSpy: jest.SpyInstance; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.requireMock('@opensumi/ide-core-browser').useInjectable.mockImplementation(() => createMockService()); + }); + + afterEach(() => { + unmountComponentAtNode(container); + container.remove(); + consoleErrorSpy.mockRestore(); + mockMentionInputOnSend = undefined; + jest.clearAllMocks(); + }); + + it('accepts a ref and exposes setInputValue without React ref warnings', () => { + const ref = React.createRef<{ setInputValue: (value: string) => void }>(); + + act(() => { + render( + React.createElement(AcpChatMentionInput, { + ref, + onSend: jest.fn(), + setTheme: jest.fn(), + agentId: '', + setAgentId: jest.fn(), + command: '', + setCommand: jest.fn(), + } as any), + container, + ); + }); + + expect(consoleErrorSpy.mock.calls.flat().join('\n')).not.toContain('Function components cannot be given refs'); + expect(ref.current?.setInputValue).toEqual(expect.any(Function)); + + act(() => { + ref.current!.setInputValue('hello from ref'); + }); + + expect((container.querySelector('[data-testid="acp-mention-input"]') as HTMLTextAreaElement).value).toBe( + 'hello from ref', + ); + }); + + it('toggles expanded state and notifies onExpand', () => { + const onExpand = jest.fn(); + + act(() => { + render( + React.createElement(AcpChatMentionInput, { + onSend: jest.fn(), + onExpand, + setTheme: jest.fn(), + agentId: '', + setAgentId: jest.fn(), + command: '', + setCommand: jest.fn(), + } as any), + container, + ); + }); + + const input = () => container.querySelector('[data-testid="acp-mention-input"]') as HTMLTextAreaElement; + const expandButton = container.querySelector('.expand_icon') as HTMLElement; + const root = container.querySelector('.chat_input_container') as HTMLElement; + expect(input().getAttribute('data-expanded')).toBe('false'); + expect(root.className).not.toContain('chat_input_container_expanded'); + expect(expandButton.querySelector('span')!.className).toContain('icon-fullescreen'); + + act(() => { + expandButton.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(input().getAttribute('data-expanded')).toBe('true'); + expect(root.className).toContain('chat_input_container_expanded'); + expect(expandButton.querySelector('span')!.className).toContain('icon-unfullscreen'); + expect(onExpand).toHaveBeenLastCalledWith(true); + + act(() => { + expandButton.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(input().getAttribute('data-expanded')).toBe('false'); + expect(root.className).not.toContain('chat_input_container_expanded'); + expect(expandButton.querySelector('span')!.className).toContain('icon-fullescreen'); + expect(onExpand).toHaveBeenLastCalledWith(false); + expect(onExpand).toHaveBeenCalledTimes(2); + }); + + it('syncs currentMode when currentModeId prop changes', () => { + const props = { + onSend: jest.fn(), + setTheme: jest.fn(), + agentId: '', + setAgentId: jest.fn(), + command: '', + setCommand: jest.fn(), + agentModes: [ + { id: 'plan', name: 'Plan Mode' }, + { id: 'code', name: 'Code Mode' }, + ], + }; + + act(() => { + render(React.createElement(AcpChatMentionInput, { ...props, currentModeId: 'plan' } as any), container); + }); + + const input = () => container.querySelector('[data-testid="acp-mention-input"]') as HTMLTextAreaElement; + expect(input().getAttribute('data-current-mode')).toBe('plan'); + + act(() => { + render(React.createElement(AcpChatMentionInput, { ...props, currentModeId: 'code' } as any), container); + }); + + expect(input().getAttribute('data-current-mode')).toBe('code'); + }); + + it('syncs model and config options when props change', () => { + const props = { + onSend: jest.fn(), + setTheme: jest.fn(), + agentId: '', + setAgentId: jest.fn(), + command: '', + setCommand: jest.fn(), + agentModels: [ + { modelId: 'old-model', name: 'Old Model' }, + { modelId: 'qwen3.6-plus', name: 'Qwen' }, + ], + }; + + act(() => { + render( + React.createElement(AcpChatMentionInput, { + ...props, + currentModelId: 'old-model', + configOptions: [{ id: 'permission', name: 'Permission' }], + } as any), + container, + ); + }); + + const input = () => container.querySelector('[data-testid="acp-mention-input"]') as HTMLTextAreaElement; + expect(input().getAttribute('data-default-model')).toBe('old-model'); + expect(input().getAttribute('data-config-option-count')).toBe('1'); + + act(() => { + render( + React.createElement(AcpChatMentionInput, { + ...props, + currentModelId: 'qwen3.6-plus', + configOptions: [ + { id: 'permission', name: 'Permission' }, + { id: 'thinking', name: 'Thinking' }, + ], + } as any), + container, + ); + }); + + expect(input().getAttribute('data-default-model')).toBe('qwen3.6-plus'); + expect(input().getAttribute('data-config-option-count')).toBe('2'); + }); + + it('does not forward whitespace-only contenteditable submits', async () => { + const onSend = jest.fn(); + + act(() => { + render( + React.createElement(AcpChatMentionInput, { + onSend, + setTheme: jest.fn(), + agentId: '', + setAgentId: jest.fn(), + command: '', + setCommand: jest.fn(), + } as any), + container, + ); + }); + + await act(async () => { + (container.querySelector('[data-testid="acp-mention-send-whitespace"]') as HTMLButtonElement).click(); + await Promise.resolve(); + }); + + expect(onSend).not.toHaveBeenCalled(); + }); + + it('does not forward contenteditable blank markup submits', async () => { + const onSend = jest.fn(); + + act(() => { + render( + React.createElement(AcpChatMentionInput, { + onSend, + setTheme: jest.fn(), + agentId: '', + setAgentId: jest.fn(), + command: '', + setCommand: jest.fn(), + } as any), + container, + ); + }); + + await act(async () => { + (container.querySelector('[data-testid="acp-mention-send-empty-html"]') as HTMLButtonElement).click(); + await Promise.resolve(); + }); + + expect(onSend).not.toHaveBeenCalled(); + }); + + it('keeps command-only contenteditable submits valid', async () => { + const onSend = jest.fn(); + + act(() => { + render( + React.createElement(AcpChatMentionInput, { + onSend, + setTheme: jest.fn(), + agentId: 'default-agent', + setAgentId: jest.fn(), + command: 'generate', + setCommand: jest.fn(), + } as any), + container, + ); + }); + + await act(async () => { + (container.querySelector('[data-testid="acp-mention-send-whitespace"]') as HTMLButtonElement).click(); + await Promise.resolve(); + }); + + expect(onSend).toHaveBeenCalledWith(' \n\t ', [], 'default-agent', 'generate', { model: 'mock-model' }); + }); + + it('returns the parent send promise to the contenteditable MentionInput', async () => { + let resolveSend!: () => void; + const sendResult = new Promise((resolve) => { + resolveSend = resolve; + }); + const onSend = jest.fn(() => sendResult); + + act(() => { + render( + React.createElement(AcpChatMentionInput, { + onSend, + setTheme: jest.fn(), + agentId: 'default-agent', + setAgentId: jest.fn(), + command: '', + setCommand: jest.fn(), + } as any), + container, + ); + }); + + let wrapperSendSettled = false; + const wrapperSendResult = mockMentionInputOnSend?.('hello', { model: 'mock-model' }) as Promise; + void wrapperSendResult.then(() => { + wrapperSendSettled = true; + }); + + await Promise.resolve(); + + expect(onSend).toHaveBeenCalledWith('hello', [], 'default-agent', '', { model: 'mock-model' }); + expect(wrapperSendSettled).toBe(false); + + resolveSend(); + await wrapperSendResult; + + expect(wrapperSendSettled).toBe(true); + }); +}); diff --git a/packages/ai-native/__test__/browser/acp-chat-relay-store.test.ts b/packages/ai-native/__test__/browser/acp-chat-relay-store.test.ts new file mode 100644 index 0000000000..71cc3c8e0f --- /dev/null +++ b/packages/ai-native/__test__/browser/acp-chat-relay-store.test.ts @@ -0,0 +1,78 @@ +import { AcpChatRelayStore } from '../../src/browser/acp/acp-chat-relay-store'; + +describe('AcpChatRelayStore', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(1000); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('stores relay digests with a generated id and expiry metadata', () => { + const store = new AcpChatRelayStore(); + + const record = store.put({ + sourceSessionId: 'acp:source', + sourceTitle: 'Source Session', + digestSource: 'background_summary', + digest: 'summary', + sourceChars: 100, + digestChars: 7, + sourceTruncated: false, + ttlMs: 5000, + }); + + expect(record).toMatchObject({ + sourceSessionId: 'acp:source', + sourceTitle: 'Source Session', + digestSource: 'background_summary', + digest: 'summary', + sourceChars: 100, + digestChars: 7, + sourceTruncated: false, + createdAt: 1000, + expiresAt: 6000, + }); + expect(record.digestId).toEqual(expect.any(String)); + expect(store.get(record.digestId)).toEqual(record); + }); + + it('drops expired records before returning them', () => { + const store = new AcpChatRelayStore(); + const record = store.put({ + sourceSessionId: 'acp:source', + sourceTitle: 'Source Session', + digestSource: 'memory_summary', + digest: 'summary', + sourceChars: 100, + digestChars: 7, + sourceTruncated: false, + ttlMs: 1000, + }); + + jest.setSystemTime(1999); + expect(store.get(record.digestId)).toEqual(record); + + jest.setSystemTime(2000); + expect(store.get(record.digestId)).toBeUndefined(); + }); + + it('deletes relay records explicitly', () => { + const store = new AcpChatRelayStore(); + const record = store.put({ + sourceSessionId: 'acp:source', + sourceTitle: 'Source Session', + digestSource: 'empty', + digest: '', + sourceChars: 0, + digestChars: 0, + sourceTruncated: false, + }); + + store.delete(record.digestId); + + expect(store.get(record.digestId)).toBeUndefined(); + }); +}); diff --git a/packages/ai-native/__test__/browser/acp-chat-relay-summary-provider.test.ts b/packages/ai-native/__test__/browser/acp-chat-relay-summary-provider.test.ts new file mode 100644 index 0000000000..9e4dad8a59 --- /dev/null +++ b/packages/ai-native/__test__/browser/acp-chat-relay-summary-provider.test.ts @@ -0,0 +1,122 @@ +import { ChatMessageRole } from '@opensumi/ide-core-common/lib/types/ai-native'; + +import { AcpChatRelaySummaryProvider } from '../../src/browser/acp/acp-chat-relay-summary-provider'; + +function createSession(options: { + memorySummaries?: Array<{ content: string; timestamp: number; messageIds: string[] }>; + messages?: Array<{ role: ChatMessageRole; content: string; id?: string; order?: number }>; +}) { + return { + sessionId: 'acp:source', + title: 'Source Session', + history: { + getMemorySummaries: jest.fn().mockReturnValue(options.memorySummaries ?? []), + getMessages: jest.fn().mockReturnValue( + (options.messages ?? []).map((message, index) => ({ + id: message.id ?? `msg-${index}`, + order: message.order ?? index, + role: message.role, + content: message.content, + })), + ), + }, + }; +} + +function createProvider(request = jest.fn()) { + const provider = new AcpChatRelaySummaryProvider(); + Object.defineProperty(provider, 'aiBackService', { + value: { request }, + }); + Object.defineProperty(provider, 'configProvider', { + value: { + resolveConfig: jest.fn().mockResolvedValue({ + agentId: 'claude-agent-acp', + command: 'claude-agent-acp', + args: [], + cwd: '/workspace', + }), + }, + }); + return provider; +} + +describe('AcpChatRelaySummaryProvider', () => { + it('uses existing memory summaries before calling the model', async () => { + const request = jest.fn(); + const provider = createProvider(request); + const session = createSession({ + memorySummaries: [ + { content: JSON.stringify({ memory: 'Earlier work was completed.' }), timestamp: 2, messageIds: ['2'] }, + { content: 'Initial investigation found a terminal issue.', timestamp: 1, messageIds: ['1'] }, + ], + }); + + const result = await provider.prepareSessionDigest(session, { maxDigestChars: 1000 }); + + expect(result).toMatchObject({ + digestSource: 'memory_summary', + digest: 'Initial investigation found a terminal issue.\n\nEarlier work was completed.', + sourceTruncated: false, + }); + expect(request).not.toHaveBeenCalled(); + }); + + it('builds bounded source material and asks the model for a background summary', async () => { + const request = jest.fn().mockResolvedValue({ + errorCode: 0, + data: '会话主要完成了终端能力验证,下一步需要补充权限确认。', + }); + const provider = createProvider(request); + const session = createSession({ + messages: [ + { role: ChatMessageRole.User, content: '帮我验证 terminal_create' }, + { role: ChatMessageRole.Assistant, content: '已验证 terminal_create 可以创建终端。' }, + { role: ChatMessageRole.Function, content: 'large tool result should be ignored' }, + ], + }); + + const result = await provider.prepareSessionDigest(session, { maxSourceChars: 1000, maxDigestChars: 1000 }); + + expect(result).toMatchObject({ + digestSource: 'background_summary', + digest: '会话主要完成了终端能力验证,下一步需要补充权限确认。', + sourceChars: expect.any(Number), + }); + expect(request).toHaveBeenCalledWith( + expect.stringContaining('Summarize this ACP chat session'), + expect.objectContaining({ + type: 'acp_chat_relay_summary', + sessionId: 'acp:source', + noTool: true, + agentSessionConfig: expect.objectContaining({ + agentId: 'claude-agent-acp', + cwd: '/workspace', + }), + messages: [ + { role: ChatMessageRole.User, content: '帮我验证 terminal_create' }, + { role: ChatMessageRole.Assistant, content: '已验证 terminal_create 可以创建终端。' }, + ], + }), + ); + }); + + it('returns an empty digest when the background summary request fails', async () => { + const request = jest.fn().mockResolvedValue({ + errorCode: -1, + errorMsg: 'request is not supported', + }); + const provider = createProvider(request); + const session = createSession({ + messages: [{ role: ChatMessageRole.User, content: '同步一下进展' }], + }); + + const result = await provider.prepareSessionDigest(session); + + expect(result).toMatchObject({ + digestSource: 'empty', + digest: '', + digestChars: 0, + }); + }); +}); diff --git a/packages/ai-native/__test__/browser/acp-chat-view-header.test.tsx b/packages/ai-native/__test__/browser/acp-chat-view-header.test.tsx new file mode 100644 index 0000000000..4e00e97139 --- /dev/null +++ b/packages/ai-native/__test__/browser/acp-chat-view-header.test.tsx @@ -0,0 +1,942 @@ +import * as React from 'react'; +import { Root, createRoot } from 'react-dom/client'; +import { act } from 'react-dom/test-utils'; + +jest.mock('react-chat-elements', () => ({ + MessageList: () => null, +})); + +jest.mock('@opensumi/ide-core-browser', () => ({ + AINativeConfigService: Symbol('AINativeConfigService'), + AppConfig: Symbol('AppConfig'), + LabelService: Symbol('LabelService'), + QuickPickService: Symbol('QuickPickService'), + getIcon: (name: string) => `icon-${name}`, + localize: (_key: string, defaultValue?: string) => defaultValue || _key, + useInjectable: jest.fn(), + useUpdateOnEvent: jest.fn(), +})); + +jest.mock('@opensumi/ide-core-browser/lib/components', () => ({ + Popover: ({ children, id, title }: { children: React.ReactNode; id?: string; title?: string }) => + require('react').createElement('div', { id, title }, children), + PopoverPosition: { + left: 'left', + }, +})); + +jest.mock('@opensumi/ide-core-browser/lib/components/ai-native', () => ({ + EnhanceIcon: ({ ariaLabel, className, onClick }: any) => + require('react').createElement('button', { + 'aria-label': ariaLabel, + className, + onClick, + type: 'button', + }), +})); + +jest.mock('@opensumi/ide-editor', () => ({ + WorkbenchEditorService: Symbol('WorkbenchEditorService'), +})); + +jest.mock('@opensumi/ide-main-layout', () => ({ + IMainLayoutService: Symbol('IMainLayoutService'), +})); + +jest.mock('@opensumi/ide-overlay', () => ({ + IMessageService: Symbol('IMessageService'), +})); + +jest.mock('@opensumi/ide-workspace', () => ({ + IWorkspaceService: Symbol('IWorkspaceService'), +})); + +jest.mock('../../src/browser/acp/components/AcpChatHistory', () => ({ + __esModule: true, + default: ({ + title, + variant, + disabled, + historyCollapsed, + historyList = [], + onNewChat, + onHistoryItemSelect, + onToggleHistoryCollapsed, + }: any) => + require('react').createElement( + 'div', + { 'data-testid': 'acp-chat-history', 'data-collapsed': String(!!historyCollapsed), 'data-variant': variant }, + title, + historyList.map((item: any) => + require('react').createElement( + 'button', + { + key: item.id, + 'data-created-at': String(item.createdAt), + 'data-testid': `acp-chat-history-item-${item.id}`, + onClick: () => onHistoryItemSelect?.(item), + type: 'button', + }, + item.title, + ), + ), + require('react').createElement( + 'button', + { + 'data-testid': 'acp-chat-history-new', + disabled, + onClick: onNewChat, + type: 'button', + }, + 'new', + ), + onToggleHistoryCollapsed && + require('react').createElement( + 'button', + { + 'data-testid': 'acp-chat-history-collapse', + onClick: onToggleHistoryCollapsed, + type: 'button', + }, + 'collapse', + ), + ), +})); + +jest.mock('../../src/browser/acp/components/AcpChatViewWrapper', () => ({ + AcpChatViewWrapper: ({ children }: { children: React.ReactNode }) => + require('react').createElement(React.Fragment, null, children), +})); + +jest.mock('../../src/browser/acp/permission-bridge.service', () => ({ + AcpPermissionBridgeService: class AcpPermissionBridgeService {}, +})); + +jest.mock('../../src/browser/layout/panel-layout.service', () => ({ + AIPanelLayoutService: class AIPanelLayoutService {}, +})); + +jest.mock('../../src/browser/chat/pick-workspace-dir', () => ({ + getCachedWorkspaceDir: jest.fn(() => '/workspace/root'), + switchWorkspaceDir: jest.fn(() => Promise.resolve('/workspace/root')), +})); + +jest.mock('../../src/browser/chat/chat-model', () => ({ + ChatModel: class ChatModel {}, + ChatRequestModel: class ChatRequestModel {}, + ChatSlashCommandItemModel: class ChatSlashCommandItemModel {}, +})); + +jest.mock('../../src/browser/components/ChangeList', () => ({ + FileListDisplay: () => null, +})); + +jest.mock('../../src/browser/components/ChatEditor', () => ({ + CodeBlockWrapperInput: () => null, +})); + +jest.mock('../../src/browser/components/ChatInput', () => ({ + ChatInput: () => null, +})); + +jest.mock('../../src/browser/components/ChatMarkdown', () => ({ + ChatMarkdown: () => null, +})); + +jest.mock('../../src/browser/components/ChatReply', () => ({ + ChatNotify: () => null, + ChatReply: () => null, +})); + +jest.mock('../../src/browser/components/SlashCustomRender', () => ({ + SlashCustomRender: () => null, +})); + +jest.mock('../../src/browser/components/utils', () => ({ + createMessageByAI: jest.fn(), + createMessageByUser: jest.fn(), +})); + +jest.mock('../../src/browser/components/WelcomeMsg', () => ({ + WelcomeMessage: () => null, +})); + +jest.mock('../../src/browser/mcp/base-apply.service', () => ({ + BaseApplyService: Symbol('BaseApplyService'), +})); + +jest.mock('../../src/browser/chat/chat-proxy.service', () => ({ + ChatProxyService: { + AGENT_ID: 'default-agent', + }, +})); + +jest.mock('../../src/browser/chat/chat.api.service', () => ({ + ChatService: Symbol('ChatService'), +})); + +jest.mock('../../src/browser/chat/chat.feature.registry', () => ({ + ChatFeatureRegistry: class ChatFeatureRegistry {}, +})); + +jest.mock('../../src/browser/chat/chat.history.registry', () => ({ + IChatHistoryRegistry: Symbol('IChatHistoryRegistry'), +})); + +jest.mock('../../src/browser/chat/chat.input.registry', () => ({ + ChatInputRegistry: class ChatInputRegistry {}, +})); + +jest.mock('../../src/browser/chat/chat.internal.service', () => ({ + ChatInternalService: class ChatInternalService {}, +})); + +jest.mock('../../src/browser/chat/chat.internal.service.acp', () => ({ + AcpChatInternalService: class AcpChatInternalService {}, +})); + +jest.mock('../../src/browser/chat/chat.render.registry', () => ({ + ChatRenderRegistry: class ChatRenderRegistry {}, +})); + +import { ChatMessageRole } from '@opensumi/ide-core-common'; + +import { AcpChatViewHeader } from '../../src/browser/acp/components/AcpChatViewHeader'; +import { AIChatViewACPContent, DefaultChatViewHeaderACP } from '../../src/browser/chat/chat.view.acp'; + +const disposable = () => ({ dispose: jest.fn() }); +const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0)); + +function createMockSession({ + createdAt, + messages, +}: { + createdAt?: number; + messages?: Array<{ + role: ChatMessageRole; + content: string; + replyStartTime?: number; + timestamp?: number; + }>; +} = {}) { + const history = { + addAssistantMessage: jest.fn(() => 'assistant-message'), + addUserMessage: jest.fn(), + getMessages: jest.fn( + () => + messages || [ + { + role: ChatMessageRole.User, + content: 'Current ACP session', + replyStartTime: 1, + }, + ], + ), + onMessageChange: jest.fn(() => disposable()), + }; + + return { + sessionId: 'acp:current', + createdAt, + title: 'Current ACP session', + history, + threadStatus: 'idle', + onThreadStatusChange: jest.fn(() => disposable()), + }; +} + +function createMockServices({ + isMultiRoot = false, + panelLayout = 'classic', + createSessionModel, + enterDraftSession, + ensureSessionModel, + createRequest, + sendRequest, + session, + sessions, +}: { + isMultiRoot?: boolean; + panelLayout?: 'classic' | 'agentic'; + createSessionModel?: jest.Mock; + createRequest?: jest.Mock; + enterDraftSession?: jest.Mock; + ensureSessionModel?: jest.Mock; + sendRequest?: jest.Mock; + session?: ReturnType | null; + sessions?: ReturnType[]; +} = {}) { + const currentSession = session === undefined ? createMockSession() : session; + const sessionList = sessions || (currentSession ? [currentSession] : []); + const panelLayoutListeners = new Set<(mode: 'classic' | 'agentic') => void>(); + let currentPanelLayout = panelLayout; + const aiChatService = { + sessionModel: currentSession, + activateSession: jest.fn(), + clearSessionModel: jest.fn(), + createRequest: + createRequest || + jest.fn(() => ({ + message: { + agentId: 'default-agent', + prompt: 'hello', + }, + requestId: 'request-1', + response: { + isComplete: false, + }, + })), + createSessionModel: createSessionModel || jest.fn(), + enterDraftSession: enterDraftSession || jest.fn(), + getDraftSessionState: jest.fn(() => ({ isDraft: false })), + ensureSessionModel: + ensureSessionModel || + jest.fn(async () => { + if (!aiChatService.sessionModel && currentSession) { + aiChatService.sessionModel = currentSession; + } + return aiChatService.sessionModel; + }), + getSessions: jest.fn(() => sessionList), + getSessionsByAcp: jest.fn(() => Promise.resolve(sessionList)), + latestRequestId: 'request-1', + onChangeSession: jest.fn(() => disposable()), + onSessionModelChange: jest.fn(() => disposable()), + onSessionLoadingChange: jest.fn(() => disposable()), + sendRequest: sendRequest || jest.fn(), + setLatestRequestId: jest.fn(), + }; + const ChatInputForTest = React.forwardRef((_props: any, _ref) => { + const props = _props; + return React.createElement( + 'div', + null, + React.createElement( + 'button', + { + 'data-testid': 'acp-chat-send', + onClick: () => props.onSend('hello'), + type: 'button', + }, + 'send', + ), + React.createElement( + 'button', + { + 'data-testid': 'acp-chat-send-whitespace', + onClick: () => props.onSend(' \n\t '), + type: 'button', + }, + 'send whitespace', + ), + React.createElement( + 'button', + { + 'data-testid': 'acp-chat-send-empty-html', + onClick: () => props.onSend('

  '), + type: 'button', + }, + 'send empty html', + ), + React.createElement( + 'button', + { + 'data-testid': 'acp-chat-send-command-only', + onClick: () => props.onSend(' ', undefined, undefined, 'generate'), + type: 'button', + }, + 'send command only', + ), + ); + }); + + return { + aiChatService, + aiNativeConfigService: { + capabilities: { + supportsAgentMode: true, + }, + }, + aiReporter: { + start: jest.fn(() => 'relation-1'), + }, + appConfig: { + workspaceDir: '/workspace/root', + }, + applyService: { + getSessionCodeBlocks: jest.fn(() => []), + onCodeBlockUpdate: jest.fn(() => disposable()), + processAll: jest.fn(), + }, + chatAgentService: { + getCommands: jest.fn(() => []), + getDefaultAgentId: jest.fn(() => undefined), + onDidChangeAgents: jest.fn(() => disposable()), + onDidSendMessage: jest.fn(() => disposable()), + }, + chatApiService: { + clearHistoryMessages: jest.fn(), + onChatMessageLaunch: jest.fn(() => disposable()), + onChatMessageListLaunch: jest.fn(() => disposable()), + onChatReplyMessageLaunch: jest.fn(() => disposable()), + onScrollToBottom: jest.fn(() => disposable()), + }, + chatFeatureRegistry: { + getAllShortcutSlashCommand: jest.fn(() => []), + getSlashCommandHandler: jest.fn(() => undefined), + getMessageSummaryProvider: jest.fn(() => undefined), + }, + chatInputRegistry: { + getActiveChatInput: jest.fn(() => ({ + component: ChatInputForTest, + })), + }, + chatRenderRegistry: {}, + commandService: {}, + editorService: { + open: jest.fn(), + }, + labelService: {}, + layoutService: { + toggleSlot: jest.fn(), + }, + llmContextService: {}, + messageService: { + error: jest.fn(), + }, + mcpServerRegistry: {}, + permissionBridgeService: { + getPendingCountExcludingActive: jest.fn(() => 0), + hasPendingForSession: jest.fn(() => false), + onActiveSessionChange: jest.fn(() => disposable()), + onPendingCountChange: jest.fn(() => disposable()), + }, + panelLayoutService: { + getLayoutMode: jest.fn(() => currentPanelLayout), + onDidChangePanelLayout: jest.fn((listener: (mode: 'classic' | 'agentic') => void) => { + panelLayoutListeners.add(listener); + return { + dispose: jest.fn(() => { + panelLayoutListeners.delete(listener); + }), + }; + }), + setLayoutModeForTest: (mode: 'classic' | 'agentic') => { + currentPanelLayout = mode; + panelLayoutListeners.forEach((listener) => listener(mode)); + }, + }, + quickPick: {}, + workspaceService: { + asRelativePath: jest.fn(async () => undefined), + isMultiRootWorkspaceOpened: isMultiRoot, + }, + }; +} + +function installInjectableMocks(services: ReturnType) { + jest.requireMock('@opensumi/ide-core-browser').useInjectable.mockImplementation((token: any) => { + const key = String(token); + const name = token?.name || ''; + + if (key.includes('IChatInternalService')) { + return services.aiChatService; + } + + if (key.includes('AINativeConfigService')) { + return services.aiNativeConfigService; + } + + if (key.includes('AppConfig')) { + return services.appConfig; + } + + if (key.includes('BaseApplyService')) { + return services.applyService; + } + + if (key.includes('ChatInputRegistry')) { + return services.chatInputRegistry; + } + + if (key.includes('ChatRenderRegistry')) { + return services.chatRenderRegistry; + } + + if (key.includes('ChatServiceToken')) { + return services.chatApiService; + } + + if (key.includes('CommandService')) { + return services.commandService; + } + + if (key.includes('ChatFeatureRegistry')) { + return services.chatFeatureRegistry; + } + + if (key.includes('IAIReporter')) { + return services.aiReporter; + } + + if (key.includes('IChatAgentService')) { + return services.chatAgentService; + } + + if (key.includes('IMainLayoutService')) { + return services.layoutService; + } + + if (key.includes('LabelService')) { + return services.labelService; + } + + if (key.includes('LLMContextServiceToken')) { + return services.llmContextService; + } + + if (key.includes('TokenMCPServerRegistry')) { + return services.mcpServerRegistry; + } + + if (key.includes('WorkbenchEditorService')) { + return services.editorService; + } + + if (key.includes('IMessageService')) { + return services.messageService; + } + + if (key.includes('IWorkspaceService')) { + return services.workspaceService; + } + + if (key.includes('QuickPickService')) { + return services.quickPick; + } + + if (name === 'AcpPermissionBridgeService') { + return services.permissionBridgeService; + } + + if (name === 'AIPanelLayoutService') { + return services.panelLayoutService; + } + + return {}; + }); +} + +describe('ACP chat view headers', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + jest.clearAllMocks(); + }); + + async function renderHeader(component: React.ReactElement) { + await act(async () => { + root.render(component); + await Promise.resolve(); + }); + } + + it('hides the clear action in the default ACP chat header while keeping history and close actions', async () => { + installInjectableMocks(createMockServices()); + + await renderHeader( + React.createElement(DefaultChatViewHeaderACP, { + handleClear: jest.fn(), + handleCloseChatView: jest.fn(), + }), + ); + + expect(container.querySelector('#ai-chat-header-clear')).toBeNull(); + expect(container.querySelector('#ai-chat-header-close')).not.toBeNull(); + expect(container.querySelector('[data-testid="acp-chat-history"]')).not.toBeNull(); + }); + + it('hides the clear action in the ACP-specific header while keeping workspace switch and close actions', async () => { + installInjectableMocks(createMockServices({ isMultiRoot: true })); + + await renderHeader( + React.createElement(AcpChatViewHeader, { + handleClear: jest.fn(), + handleCloseChatView: jest.fn(), + }), + ); + + expect(container.querySelector('#ai-chat-header-clear')).toBeNull(); + expect(container.querySelector('#ai-chat-header-switch-cwd')).not.toBeNull(); + expect(container.querySelector('#ai-chat-header-close')).not.toBeNull(); + expect(container.querySelector('[data-testid="acp-chat-history"]')).not.toBeNull(); + }); + + it('uses popover history in the ACP-specific header when panel layout is classic', async () => { + installInjectableMocks(createMockServices({ panelLayout: 'classic' })); + + await renderHeader( + React.createElement(AcpChatViewHeader, { + handleClear: jest.fn(), + handleCloseChatView: jest.fn(), + }), + ); + + expect(container.querySelector('[data-testid="acp-chat-history"]')?.getAttribute('data-variant')).toBe('popover'); + }); + + it('passes session creation time to the ACP-specific history list', async () => { + installInjectableMocks(createMockServices({ session: createMockSession({ createdAt: 12345 }) })); + + await renderHeader( + React.createElement(AcpChatViewHeader, { + handleClear: jest.fn(), + handleCloseChatView: jest.fn(), + }), + ); + + expect( + container.querySelector('[data-testid="acp-chat-history-item-acp:current"]')?.getAttribute('data-created-at'), + ).toBe('12345'); + }); + + it('falls back to the first message timestamp for default ACP history creation time', async () => { + installInjectableMocks( + createMockServices({ + session: createMockSession({ + messages: [ + { + role: ChatMessageRole.User, + content: 'Current ACP session', + timestamp: 67890, + }, + ], + }), + }), + ); + + await renderHeader( + React.createElement(DefaultChatViewHeaderACP, { + handleClear: jest.fn(), + handleCloseChatView: jest.fn(), + }), + ); + + expect( + container.querySelector('[data-testid="acp-chat-history-item-acp:current"]')?.getAttribute('data-created-at'), + ).toBe('67890'); + }); + + it('uses inline history in the ACP-specific header when panel layout is agentic', async () => { + installInjectableMocks(createMockServices({ panelLayout: 'agentic' })); + + await renderHeader( + React.createElement(AcpChatViewHeader, { + handleClear: jest.fn(), + handleCloseChatView: jest.fn(), + }), + ); + + expect(container.querySelector('[data-testid="acp-chat-history"]')?.getAttribute('data-variant')).toBe('inline'); + expect(container.querySelector('#ai-chat-header-close')).toBeNull(); + }); + + it('collapses ACP history in agentic layout when the collapse action is clicked', async () => { + installInjectableMocks(createMockServices({ panelLayout: 'agentic' })); + + await renderHeader( + React.createElement(AcpChatViewHeader, { + handleClear: jest.fn(), + handleCloseChatView: jest.fn(), + }), + ); + + expect(container.querySelector('[data-testid="acp-chat-history"]')?.getAttribute('data-collapsed')).toBe('false'); + + await act(async () => { + (container.querySelector('[data-testid="acp-chat-history-collapse"]') as HTMLButtonElement).click(); + await Promise.resolve(); + }); + + expect(container.querySelector('[data-testid="acp-chat-history"]')?.getAttribute('data-collapsed')).toBe('true'); + }); + + it('updates the ACP-specific history variant when panel layout changes at runtime', async () => { + const services = createMockServices({ panelLayout: 'classic' }); + installInjectableMocks(services); + + await renderHeader( + React.createElement(AcpChatViewHeader, { + handleClear: jest.fn(), + handleCloseChatView: jest.fn(), + }), + ); + + expect(container.querySelector('[data-testid="acp-chat-history"]')?.getAttribute('data-variant')).toBe('popover'); + + await act(async () => { + services.panelLayoutService.setLayoutModeForTest('agentic'); + await Promise.resolve(); + }); + + expect(container.querySelector('[data-testid="acp-chat-history"]')?.getAttribute('data-variant')).toBe('inline'); + }); + + it('enters draft when creating a new ACP chat without creating a session', async () => { + const createSessionModel = jest.fn(); + const enterDraftSession = jest.fn(); + const services = createMockServices({ panelLayout: 'agentic', createSessionModel, enterDraftSession }); + installInjectableMocks(services); + + await renderHeader( + React.createElement(AcpChatViewHeader, { + handleClear: jest.fn(), + handleCloseChatView: jest.fn(), + }), + ); + + const newChatButton = container.querySelector('[data-testid="acp-chat-history-new"]') as HTMLButtonElement; + + await act(async () => { + newChatButton.click(); + await Promise.resolve(); + }); + expect(enterDraftSession).toHaveBeenCalledTimes(1); + expect(createSessionModel).not.toHaveBeenCalled(); + }); + + it('enters draft when switching ACP workspace cwd without creating a session', async () => { + const pickWorkspaceDir = jest.requireMock('../../src/browser/chat/pick-workspace-dir'); + pickWorkspaceDir.switchWorkspaceDir.mockResolvedValueOnce('/workspace/next'); + const createSessionModel = jest.fn(); + const enterDraftSession = jest.fn(); + const services = createMockServices({ + isMultiRoot: true, + createSessionModel, + enterDraftSession, + }); + installInjectableMocks(services); + + await renderHeader( + React.createElement(AcpChatViewHeader, { + handleClear: jest.fn(), + handleCloseChatView: jest.fn(), + }), + ); + + const switchButton = container.querySelector('#ai-chat-header-switch-cwd button') as HTMLButtonElement; + await act(async () => { + switchButton.click(); + await Promise.resolve(); + }); + + expect(enterDraftSession).toHaveBeenCalledTimes(1); + expect(createSessionModel).not.toHaveBeenCalled(); + }); + + it('keeps history item selection activating the selected ACP session', async () => { + const historySession = createMockSession({ createdAt: 1 }); + const services = createMockServices({ sessions: [historySession] }); + installInjectableMocks(services); + + await renderHeader( + React.createElement(AcpChatViewHeader, { + handleClear: jest.fn(), + handleCloseChatView: jest.fn(), + }), + ); + + await act(async () => { + (container.querySelector('[data-testid="acp-chat-history-item-acp:current"]') as HTMLButtonElement).click(); + await Promise.resolve(); + }); + + expect(services.aiChatService.activateSession).toHaveBeenCalledWith('acp:current'); + }); + + it('creates an ACP session on first draft send before writing chat history', async () => { + const session = createMockSession({ messages: [] }); + const createRequest = jest.fn(() => ({ + message: { + agentId: 'default-agent', + prompt: 'hello', + }, + requestId: 'request-1', + response: { + isComplete: false, + }, + })); + const ensureSessionModel = jest.fn(async () => session); + const sendRequest = jest.fn(); + const services = createMockServices({ + createRequest, + ensureSessionModel, + sendRequest, + session: null, + sessions: [], + }); + installInjectableMocks(services); + + await renderHeader(React.createElement(AIChatViewACPContent)); + + await act(async () => { + (container.querySelector('[data-testid="acp-chat-send"]') as HTMLButtonElement).click(); + await flushPromises(); + }); + + expect(ensureSessionModel).toHaveBeenCalledTimes(1); + expect(createRequest).toHaveBeenCalledWith('hello', 'default-agent', undefined, undefined); + expect(ensureSessionModel.mock.invocationCallOrder[0]).toBeLessThan(createRequest.mock.invocationCallOrder[0]); + expect(session.history.addUserMessage).toHaveBeenCalledWith( + expect.objectContaining({ + content: 'hello', + relationId: 'relation-1', + }), + ); + expect(session.history.addAssistantMessage).toHaveBeenCalledWith( + expect.objectContaining({ + relationId: 'relation-1', + requestId: 'request-1', + }), + ); + expect(sendRequest).toHaveBeenCalledWith(createRequest.mock.results[0].value); + expect(services.mcpServerRegistry).toEqual({ + activeMessageInfo: { + messageId: 'assistant-message', + sessionId: 'acp:current', + }, + }); + }); + + it('renders ACP replies with Deep Thinking collapsed by default', async () => { + const session = createMockSession({ messages: [] }); + const createRequest = jest.fn(() => ({ + message: { + agentId: 'default-agent', + prompt: 'hello', + }, + requestId: 'request-1', + response: { + isComplete: false, + }, + })); + const services = createMockServices({ + createRequest, + ensureSessionModel: jest.fn(async () => session), + session: null, + sessions: [], + }); + installInjectableMocks(services); + + await renderHeader(React.createElement(AIChatViewACPContent)); + + await act(async () => { + (container.querySelector('[data-testid="acp-chat-send"]') as HTMLButtonElement).click(); + await flushPromises(); + }); + + const createMessageByAI = jest.requireMock('../../src/browser/components/utils').createMessageByAI as jest.Mock; + const chatReplyElement = createMessageByAI.mock.calls + .map(([message]) => message.text) + .find((text) => text?.props?.request); + expect(chatReplyElement.props.collapseReasoningByDefault).toBe(true); + expect(chatReplyElement.props.keepReasoningExpandedOnComplete).toBeUndefined(); + }); + + it('ignores whitespace-only draft sends before creating an ACP session', async () => { + const createRequest = jest.fn(); + const ensureSessionModel = jest.fn(); + const sendRequest = jest.fn(); + const services = createMockServices({ + createRequest, + ensureSessionModel, + sendRequest, + session: null, + sessions: [], + }); + installInjectableMocks(services); + + await renderHeader(React.createElement(AIChatViewACPContent)); + + await act(async () => { + (container.querySelector('[data-testid="acp-chat-send-whitespace"]') as HTMLButtonElement).click(); + await flushPromises(); + }); + + expect(ensureSessionModel).not.toHaveBeenCalled(); + expect(createRequest).not.toHaveBeenCalled(); + expect(sendRequest).not.toHaveBeenCalled(); + }); + + it('ignores contenteditable blank markup before creating an ACP session', async () => { + const createRequest = jest.fn(); + const ensureSessionModel = jest.fn(); + const sendRequest = jest.fn(); + const services = createMockServices({ + createRequest, + ensureSessionModel, + sendRequest, + session: null, + sessions: [], + }); + installInjectableMocks(services); + + await renderHeader(React.createElement(AIChatViewACPContent)); + + await act(async () => { + (container.querySelector('[data-testid="acp-chat-send-empty-html"]') as HTMLButtonElement).click(); + await flushPromises(); + }); + + expect(ensureSessionModel).not.toHaveBeenCalled(); + expect(createRequest).not.toHaveBeenCalled(); + expect(sendRequest).not.toHaveBeenCalled(); + }); + + it('keeps command-only ACP sends valid', async () => { + const session = createMockSession({ messages: [] }); + const createRequest = jest.fn(() => ({ + message: { + agentId: 'default-agent', + command: 'generate', + prompt: ' ', + }, + requestId: 'request-1', + response: { + isComplete: false, + }, + })); + const ensureSessionModel = jest.fn(async () => session); + const sendRequest = jest.fn(); + const services = createMockServices({ + createRequest, + ensureSessionModel, + sendRequest, + session: null, + sessions: [], + }); + installInjectableMocks(services); + + await renderHeader(React.createElement(AIChatViewACPContent)); + + await act(async () => { + (container.querySelector('[data-testid="acp-chat-send-command-only"]') as HTMLButtonElement).click(); + await flushPromises(); + }); + + expect(ensureSessionModel).toHaveBeenCalledTimes(1); + expect(createRequest).toHaveBeenCalledWith(' ', 'default-agent', undefined, 'generate'); + expect(sendRequest).toHaveBeenCalledWith(createRequest.mock.results[0].value); + }); +}); diff --git a/packages/ai-native/__test__/browser/acp-chat-view-wrapper.test.tsx b/packages/ai-native/__test__/browser/acp-chat-view-wrapper.test.tsx new file mode 100644 index 0000000000..c1a5dc1c67 --- /dev/null +++ b/packages/ai-native/__test__/browser/acp-chat-view-wrapper.test.tsx @@ -0,0 +1,202 @@ +import * as React from 'react'; +import { Root, createRoot } from 'react-dom/client'; +import { act } from 'react-dom/test-utils'; + +jest.mock('@opensumi/ide-core-browser', () => ({ + AINativeConfigService: Symbol('AINativeConfigService'), + useInjectable: jest.fn(), +})); + +jest.mock('@opensumi/ide-core-browser/lib/progress/progress-bar', () => ({ + Progress: () => require('react').createElement('div', { 'data-testid': 'progress' }), +})); + +jest.mock('@opensumi/ide-core-common', () => ({ + AIBackSerivcePath: Symbol('AIBackSerivcePath'), + localize: (_key: string, defaultValue?: string) => defaultValue || _key, +})); + +jest.mock('../../src/common', () => ({ + ChatProxyServiceToken: Symbol('ChatProxyServiceToken'), + IChatManagerService: Symbol('IChatManagerService'), +})); + +jest.mock('../../src/browser/chat/chat-manager.service.acp', () => ({ + AcpChatManagerService: class AcpChatManagerService {}, +})); + +jest.mock('../../src/browser/chat/chat-proxy.service.acp', () => ({ + AcpChatProxyService: class AcpChatProxyService {}, +})); + +jest.mock('../../src/browser/chat/chat.internal.service', () => ({ + ChatInternalService: class ChatInternalService {}, +})); + +import { AcpChatViewWrapper } from '../../src/browser/acp/components/AcpChatViewWrapper'; + +const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0)); + +function createServices({ + createSessionModel = jest.fn(() => Promise.resolve()), + ensureBootstrapSessionModel = jest.fn(() => Promise.resolve(undefined)), + ready = jest.fn(() => Promise.resolve(true)), + sessionModel, + supportsAgentMode = true, +}: { + createSessionModel?: jest.Mock; + ensureBootstrapSessionModel?: jest.Mock; + ready?: jest.Mock; + sessionModel?: unknown; + supportsAgentMode?: boolean; +} = {}) { + const aiBackService = { + ready, + }; + const aiChatService = { + init: jest.fn(), + createSessionModel, + ensureBootstrapSessionModel, + sessionModel, + }; + const chatManagerService = { + fallbackToLocal: jest.fn(), + loadSessionList: jest.fn(() => Promise.resolve()), + }; + const chatProxyService = { + registerFallbackAgent: jest.fn(), + }; + + jest.requireMock('@opensumi/ide-core-browser').useInjectable.mockImplementation((token: any) => { + const key = String(token); + const name = token?.name || ''; + + if (key.includes('AINativeConfigService')) { + return { capabilities: { supportsAgentMode } }; + } + + if (key.includes('AIBackSerivcePath')) { + return aiBackService; + } + + if (key.includes('IChatManagerService') || name === 'AcpChatManagerService') { + return chatManagerService; + } + + if (key.includes('ChatProxyServiceToken') || name === 'AcpChatProxyService') { + return chatProxyService; + } + + return {}; + }); + + return { + aiBackService, + aiChatService, + chatManagerService, + chatProxyService, + }; +} + +describe('AcpChatViewWrapper', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + jest.clearAllMocks(); + }); + + async function renderWrapper(aiChatService: any) { + await act(async () => { + root.render( + React.createElement( + AcpChatViewWrapper, + { aiChatService }, + React.createElement('div', { 'data-testid': 'child' }, 'child'), + ), + ); + }); + await act(async () => { + await flushPromises(); + }); + } + + it('initializes ACP and creates one bootstrap session before rendering children', async () => { + const services = createServices(); + + await renderWrapper(services.aiChatService); + + expect(services.aiBackService.ready).toHaveBeenCalled(); + expect(services.aiChatService.init).toHaveBeenCalledTimes(1); + expect(services.aiChatService.createSessionModel).not.toHaveBeenCalled(); + expect(services.aiChatService.ensureBootstrapSessionModel).toHaveBeenCalledTimes(1); + expect(services.chatManagerService.loadSessionList).toHaveBeenCalledTimes(1); + expect(container.querySelector('[data-testid="child"]')).not.toBeNull(); + }); + + it('does not create another ACP session when one is already active', async () => { + const services = createServices({ + sessionModel: { sessionId: 'acp:existing-session' }, + }); + + await renderWrapper(services.aiChatService); + + expect(services.aiBackService.ready).toHaveBeenCalled(); + expect(services.aiChatService.init).toHaveBeenCalledTimes(1); + expect(services.aiChatService.createSessionModel).not.toHaveBeenCalled(); + expect(services.aiChatService.ensureBootstrapSessionModel).toHaveBeenCalledTimes(1); + expect(services.chatManagerService.loadSessionList).toHaveBeenCalledTimes(1); + expect(container.querySelector('[data-testid="child"]')).not.toBeNull(); + }); + + it('renders children without falling back when bootstrap session creation fails', async () => { + const services = createServices({ + ensureBootstrapSessionModel: jest.fn(() => Promise.reject(new Error('session/new failed'))), + }); + + await renderWrapper(services.aiChatService); + + expect(services.chatManagerService.fallbackToLocal).not.toHaveBeenCalled(); + expect(services.chatProxyService.registerFallbackAgent).not.toHaveBeenCalled(); + expect(services.aiChatService.createSessionModel).not.toHaveBeenCalled(); + expect(container.querySelector('[data-testid="child"]')).not.toBeNull(); + }); + + it('does not bootstrap when agent mode is disabled', async () => { + const services = createServices({ + supportsAgentMode: false, + }); + + await renderWrapper(services.aiChatService); + + expect(services.aiBackService.ready).not.toHaveBeenCalled(); + expect(services.aiChatService.init).not.toHaveBeenCalled(); + expect(services.aiChatService.ensureBootstrapSessionModel).not.toHaveBeenCalled(); + expect(services.chatManagerService.loadSessionList).not.toHaveBeenCalled(); + expect(container.querySelector('[data-testid="child"]')).not.toBeNull(); + }); + + it('falls back and creates a local session when ACP backend is unavailable', async () => { + const services = createServices({ + ready: jest.fn(() => Promise.reject(new Error('not ready'))), + }); + + await renderWrapper(services.aiChatService); + + expect(services.chatManagerService.fallbackToLocal).toHaveBeenCalledTimes(1); + expect(services.chatProxyService.registerFallbackAgent).toHaveBeenCalledTimes(1); + expect(services.aiChatService.createSessionModel).toHaveBeenCalledTimes(1); + expect(services.aiChatService.ensureBootstrapSessionModel).not.toHaveBeenCalled(); + expect(container.querySelector('[data-testid="child"]')).not.toBeNull(); + }); +}); diff --git a/packages/ai-native/__test__/browser/acp-debug-log.test.tsx b/packages/ai-native/__test__/browser/acp-debug-log.test.tsx new file mode 100644 index 0000000000..e48b80d653 --- /dev/null +++ b/packages/ai-native/__test__/browser/acp-debug-log.test.tsx @@ -0,0 +1,188 @@ +import * as React from 'react'; +import { Root, createRoot } from 'react-dom/client'; +import { act } from 'react-dom/test-utils'; + +import { AIBackSerivcePath, IClipboardService, URI } from '@opensumi/ide-core-common'; +import { IMessageService } from '@opensumi/ide-overlay'; + +import { + ACP_DEBUG_LOG_SCHEME_ID, + AcpDebugLogCommands, + AcpDebugLogContribution, +} from '../../src/browser/acp/debug-log/acp-debug-log.contribution'; +import { AcpDebugLogView } from '../../src/browser/acp/debug-log/acp-debug-log.view'; + +const useInjectable = jest.fn(); + +jest.mock('@opensumi/ide-core-browser', () => ({ + useInjectable: (...args: unknown[]) => useInjectable(...args), +})); + +jest.mock('@opensumi/ide-editor/lib/browser/types', () => ({ + BrowserEditorContribution: Symbol('BrowserEditorContribution'), + EditorComponentRenderMode: { + ONE_PER_WORKBENCH: 'ONE_PER_WORKBENCH', + }, + ResourceService: Symbol('ResourceService'), + WorkbenchEditorService: Symbol('WorkbenchEditorService'), +})); + +jest.mock('../../src/browser/acp/debug-log/acp-debug-log.module.less', () => ({ + actions: 'actions', + container: 'container', + description: 'description', + empty: 'empty', + header: 'header', + log: 'log', + title: 'title', +})); + +describe('AcpDebugLogContribution', () => { + it('registers a command that opens the ACP debug log editor', () => { + const contribution = new AcpDebugLogContribution(); + const open = jest.fn(); + Object.defineProperty(contribution, 'editorService', { + configurable: true, + value: { open }, + }); + + let handler: { execute: () => void } | undefined; + const registry = { + registerCommand: jest.fn((_command, commandHandler) => { + handler = commandHandler; + }), + }; + + contribution.registerCommands(registry as any); + handler!.execute(); + + expect(registry.registerCommand).toHaveBeenCalledWith(AcpDebugLogCommands.OPEN_ACP_DEBUG_LOG, expect.any(Object)); + expect(open).toHaveBeenCalledWith(expect.objectContaining({ scheme: ACP_DEBUG_LOG_SCHEME_ID }), { + focus: true, + preview: false, + }); + }); + + it('registers the readonly editor component resource', async () => { + const contribution = new AcpDebugLogContribution(); + const editorRegistry = { + registerEditorComponent: jest.fn(), + registerEditorComponentResolver: jest.fn(), + }; + const resourceService = { + registerResourceProvider: jest.fn(), + }; + + contribution.registerEditorComponent(editorRegistry as any); + contribution.registerResource(resourceService as any); + + expect(editorRegistry.registerEditorComponent).toHaveBeenCalledWith( + expect.objectContaining({ + component: AcpDebugLogView, + scheme: ACP_DEBUG_LOG_SCHEME_ID, + }), + ); + const provider = resourceService.registerResourceProvider.mock.calls[0][0]; + const resource = await provider.provideResource(new URI().withScheme(ACP_DEBUG_LOG_SCHEME_ID)); + expect(resource.name).toBe('ACP Debug Log'); + }); +}); + +describe('AcpDebugLogView', () => { + let container: HTMLDivElement; + let root: Root; + let aiBackService: { + clearAcpDebugLog: jest.Mock>; + getAcpDebugLog: jest.Mock>; + }; + let clipboardService: { writeText: jest.Mock> }; + let messageService: { error: jest.Mock }; + + beforeEach(() => { + jest.useFakeTimers(); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + aiBackService = { + clearAcpDebugLog: jest.fn(async () => undefined), + getAcpDebugLog: jest.fn(async () => [ + { + agentId: 'codex', + direction: 'incoming', + id: 1, + payload: { jsonrpc: '2.0' }, + raw: '{"jsonrpc":"2.0"}', + sessionId: 'session-1', + threadId: 'thread-1', + timestamp: 1710000000000, + }, + ]), + }; + clipboardService = { writeText: jest.fn(async () => undefined) }; + messageService = { error: jest.fn() }; + useInjectable.mockImplementation((token) => { + if (token === AIBackSerivcePath) { + return aiBackService; + } + if (token === IClipboardService) { + return clipboardService; + } + if (token === IMessageService) { + return messageService; + } + return undefined; + }); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + document.body.removeChild(container); + jest.useRealTimers(); + jest.clearAllMocks(); + }); + + it('loads, copies, clears, and renders an empty state', async () => { + await act(async () => { + root.render(); + await Promise.resolve(); + }); + + expect(container.textContent).toContain('ACP Debug Log'); + expect(container.textContent).toContain('agent=codex'); + expect(container.textContent).toContain('{"jsonrpc":"2.0"}'); + + const buttons = Array.from(container.querySelectorAll('button')); + await act(async () => { + buttons.find((button) => button.textContent === 'Copy All')!.click(); + await Promise.resolve(); + }); + expect(clipboardService.writeText).toHaveBeenCalledWith(expect.stringContaining('thread=thread-1')); + + await act(async () => { + buttons.find((button) => button.textContent === 'Clear')!.click(); + await Promise.resolve(); + }); + expect(aiBackService.clearAcpDebugLog).toHaveBeenCalled(); + expect(container.textContent).toContain('No ACP debug log entries yet.'); + }); + + it('refreshes logs on demand', async () => { + await act(async () => { + root.render(); + await Promise.resolve(); + }); + aiBackService.getAcpDebugLog.mockResolvedValueOnce([]); + + await act(async () => { + Array.from(container.querySelectorAll('button')) + .find((button) => button.textContent === 'Refresh')! + .click(); + await Promise.resolve(); + }); + + expect(aiBackService.getAcpDebugLog).toHaveBeenCalledTimes(2); + expect(container.textContent).toContain('No ACP debug log entries yet.'); + }); +}); diff --git a/packages/ai-native/__test__/browser/acp-mention-input-context-cleanup.test.tsx b/packages/ai-native/__test__/browser/acp-mention-input-context-cleanup.test.tsx new file mode 100644 index 0000000000..3c61107de4 --- /dev/null +++ b/packages/ai-native/__test__/browser/acp-mention-input-context-cleanup.test.tsx @@ -0,0 +1,189 @@ +import * as React from 'react'; +import { Root, createRoot } from 'react-dom/client'; +import { act } from 'react-dom/test-utils'; + +import { URI } from '@opensumi/ide-utils'; + +jest.mock('@opensumi/ide-core-browser', () => ({ + getSymbolIcon: jest.fn(() => 'symbol-icon'), + localize: (key: string) => key, + useInjectable: jest.fn(), +})); + +jest.mock('@opensumi/ide-core-browser/lib/components', () => ({ + Icon: ({ className, iconClass, onClick }: { className?: string; iconClass?: string; onClick?: () => void }) => + require('react').createElement('span', { className: className || iconClass, onClick }), + Popover: ({ children }: { children: React.ReactNode }) => require('react').createElement('div', null, children), + PopoverPosition: { + top: 'top', + }, + getIcon: (name: string) => `icon-${name}`, +})); + +jest.mock('@opensumi/ide-core-browser/lib/components/ai-native', () => ({ + EnhanceIcon: ({ + ariaLabel, + className, + onClick, + role, + tabIndex, + wrapperClassName, + }: { + ariaLabel?: string; + className?: string; + onClick?: () => void; + role?: string; + tabIndex?: number; + wrapperClassName?: string; + }) => + require('react').createElement( + 'button', + { + 'aria-label': ariaLabel, + className: [wrapperClassName, className].filter(Boolean).join(' '), + onClick, + role, + tabIndex, + type: 'button', + }, + ariaLabel, + ), +})); + +jest.mock('../../src/browser/acp/permission-dialog-container', () => ({ + PermissionDialogManager: Symbol('PermissionDialogManager'), +})); + +jest.mock('../../src/browser/components/permission-dialog-widget', () => ({ + PermissionDialogWidget: () => null, +})); + +jest.mock('../../src/browser/chat/chat-input-footer.registry', () => ({ + ChatInputFooterRegistry: jest.fn(), + ChatInputFooterRegistryToken: Symbol('ChatInputFooterRegistryToken'), +})); + +jest.mock('../../src/browser/components/mention-input/mention-panel', () => ({ + MentionPanel: () => require('react').createElement('div'), +})); + +jest.mock('../../src/browser/components/mention-input/mention-select', () => ({ + MentionSelect: () => require('react').createElement('select'), +})); + +import { MentionInput } from '../../src/browser/components/acp/MentionInput'; + +function createContextService() { + const listeners: Array<(event: any) => void> = []; + const contextService = { + addFileToContext: jest.fn(), + addFolderToContext: jest.fn(), + addRuleToContext: jest.fn(), + cleanFileContext: jest.fn(() => { + listeners.forEach((listener) => + listener({ + attached: [], + attachedFolders: [], + attachedRules: [], + viewed: [], + version: 2, + }), + ); + }), + onDidContextFilesChangeEvent: jest.fn((listener: (event: any) => void) => { + listeners.push(listener); + return { dispose: jest.fn() }; + }), + removeFileFromContext: jest.fn(), + removeFolderFromContext: jest.fn(), + removeRuleFromContext: jest.fn(), + serialize: jest.fn(), + }; + + return { + contextService, + emitAttachedFile: (uri: URI) => { + listeners.forEach((listener) => + listener({ + attached: [{ uri }], + attachedFolders: [], + attachedRules: [], + viewed: [], + version: 1, + }), + ); + }, + }; +} + +describe('ACP MentionInput context cleanup', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + jest.requireMock('@opensumi/ide-core-browser').useInjectable.mockReturnValue({ + getItems: jest.fn(() => []), + onDidChange: jest.fn(() => ({ dispose: jest.fn() })), + }); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + jest.clearAllMocks(); + }); + + it('clears footer context preview state after a context-chip send settles', async () => { + let resolveSend!: () => void; + const sendResult = new Promise((resolve) => { + resolveSend = resolve; + }); + const onSend = jest.fn(() => sendResult); + const fileUri = URI.file('/workspace/editor.js'); + const { contextService, emitAttachedFile } = createContextService(); + + act(() => { + root.render( + React.createElement(MentionInput, { + contextService, + footerConfig: { buttons: [], showModelSelector: false }, + labelService: { getIcon: jest.fn(() => 'file-icon') }, + onSend, + workspaceService: { workspace: { uri: URI.file('/workspace').toString() } }, + } as any), + ); + }); + + act(() => { + emitAttachedFile(fileUri); + }); + + expect(container.querySelector('.context_preview_item[data-type="file"]')?.textContent).toContain('editor.js'); + + const editor = container.querySelector('.editor') as HTMLDivElement; + editor.innerHTML = `editor.js BDD context attachment chip send`; + + act(() => { + (container.querySelector('button[aria-label="Send"]') as HTMLButtonElement).click(); + }); + + expect(onSend).toHaveBeenCalledTimes(1); + expect(editor.innerHTML).toBe(''); + expect(contextService.cleanFileContext).not.toHaveBeenCalled(); + expect(container.querySelector('.context_preview_item[data-type="file"]')?.textContent).toContain('editor.js'); + + await act(async () => { + resolveSend(); + await sendResult; + await Promise.resolve(); + }); + + expect(contextService.cleanFileContext).toHaveBeenCalledTimes(1); + expect(container.querySelector('.context_preview_item[data-type="file"]')).toBeNull(); + }); +}); 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..a27fadd86a --- /dev/null +++ b/packages/ai-native/__test__/browser/acp/build-agent-process-config.test.ts @@ -0,0 +1,230 @@ +import { DEFAULT_ACP_THREAD_POOL_SIZE } from '@opensumi/ide-core-common/lib/settings/ai-native'; +import { EnvVariable, McpServer } from '@opensumi/ide-core-common/lib/types/ai-native/acp-types'; + +import { buildAcpAgentProcessConfig } from '../../../src/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, + threadPoolSize: DEFAULT_ACP_THREAD_POOL_SIZE, + }); + }); + + 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); + }); + + it('includes WebMCP enabled preference when provided', () => { + const result = buildAcpAgentProcessConfig({ + agentId: 'test-agent', + registration: defaultRegistration, + userPreferences: { + ...defaultPrefs, + webMcpEnabled: false, + }, + }); + expect(result.webMcp).toEqual({ enabled: false }); + }); + + it('includes configured ACP thread pool size when provided', () => { + const result = buildAcpAgentProcessConfig({ + agentId: 'test-agent', + registration: defaultRegistration, + userPreferences: { + ...defaultPrefs, + threadPoolSize: 5, + }, + }); + expect(result.threadPoolSize).toBe(5); + }); + + it('falls back to default ACP thread pool size when preference is invalid', () => { + const result = buildAcpAgentProcessConfig({ + agentId: 'test-agent', + registration: defaultRegistration, + userPreferences: { + ...defaultPrefs, + threadPoolSize: 0, + }, + }); + expect(result.threadPoolSize).toBe(DEFAULT_ACP_THREAD_POOL_SIZE); + }); + + it('includes ACP session defaults from per-agent overrides', () => { + const defaultConfigOptions = { + permission: 'acceptEdits', + thinking: true, + }; + const result = buildAcpAgentProcessConfig({ + agentId: 'test-agent', + registration: defaultRegistration, + userPreferences: { + ...defaultPrefs, + agents: { + 'test-agent': { + defaultModel: 'gpt-5', + defaultMode: 'plan', + defaultConfigOptions, + }, + }, + }, + }); + + expect(result.defaultModel).toBe('gpt-5'); + expect(result.defaultMode).toBe('plan'); + expect(result.defaultConfigOptions).toBe(defaultConfigOptions); + }); + + it('keeps existing spawn overrides while adding ACP session defaults', () => { + const result = buildAcpAgentProcessConfig({ + agentId: 'test-agent', + registration: defaultRegistration, + userPreferences: { + nodePath: '/usr/local/bin/node', + webMcpEnabled: true, + agents: { + 'test-agent': { + command: '/custom/bin/agent', + args: ['--acp'], + env: { API_KEY: 'user-value' }, + defaultModel: 'claude-sonnet', + defaultMode: 'code', + defaultConfigOptions: { approval: 'on-request' }, + }, + }, + }, + }); + + expect(result).toEqual( + expect.objectContaining({ + agentId: 'test-agent', + command: '/custom/bin/agent', + args: ['--acp'], + cwd: '/workspace', + nodePath: '/usr/local/bin/node', + threadPoolSize: DEFAULT_ACP_THREAD_POOL_SIZE, + defaultModel: 'claude-sonnet', + defaultMode: 'code', + defaultConfigOptions: { approval: 'on-request' }, + webMcp: { enabled: true }, + }), + ); + expect(new Map(result.env!.map((v) => [v.name, v.value])).get('API_KEY')).toBe('user-value'); + }); +}); 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/ai-layout.test.tsx b/packages/ai-native/__test__/browser/ai-layout.test.tsx new file mode 100644 index 0000000000..fd108e2c77 --- /dev/null +++ b/packages/ai-native/__test__/browser/ai-layout.test.tsx @@ -0,0 +1,537 @@ +import React from 'react'; +import { Root, createRoot } from 'react-dom/client'; +import { act } from 'react-dom/test-utils'; + +let panelLayoutMode: 'classic' | 'agentic' = 'classic'; +let storedLayout: Record = {}; +let storedLayouts: Record> = {}; +const mockToggleSlot = jest.fn(); +const mockPreferenceServiceToken = Symbol('PreferenceService'); +let panelLayoutChangeListener: ((mode: 'classic' | 'agentic') => void) | undefined; + +jest.mock('@opensumi/ide-core-browser', () => { + const React = require('react'); + return { + SlotLocation: { + top: 'top', + view: 'view', + extendView: 'extendView', + main: 'main', + statusBar: 'statusBar', + panel: 'panel', + }, + IClientApp: Symbol('CLIENT_APP_TOKEN'), + PreferenceService: mockPreferenceServiceToken, + runWhenIdle: (callback: () => void) => { + callback(); + return { dispose: jest.fn() }; + }, + SlotRenderer: ({ + slot, + id, + defaultSize, + maxResize, + minResize, + minSize, + }: { + slot: string; + id?: string; + defaultSize?: number; + maxResize?: number; + minResize?: number; + minSize?: number; + }) => + React.createElement('div', { + 'data-slot': slot, + 'data-id': id, + 'data-default-size': defaultSize, + 'data-max-resize': maxResize, + 'data-min-resize': minResize, + 'data-min-size': minSize, + }), + useInjectable: (token: any) => { + if (token.name === 'DesignLayoutConfig') { + return { useMergeRightWithLeftPanel: false }; + } + if (token.name === 'AIPanelLayoutService') { + return { + getLayoutMode: () => panelLayoutMode, + onDidChangePanelLayout: (listener: (mode: 'classic' | 'agentic') => void) => { + panelLayoutChangeListener = listener; + return { dispose: jest.fn() }; + }, + }; + } + if (String(token) === 'Symbol(CLIENT_APP_TOKEN)') { + return { + appInitialized: { + promise: new Promise(() => {}), + }, + }; + } + if (token === mockPreferenceServiceToken) { + return { + ready: { + then: (callback: () => void) => { + callback(); + return Promise.resolve(); + }, + }, + }; + } + if (String(token) === 'Symbol(IMainLayoutService)') { + return { + toggleSlot: mockToggleSlot, + setLayoutStateKey: jest.fn(), + getTabbarService: () => ({ + viewReady: { + promise: new Promise(() => {}), + }, + }), + }; + } + return {}; + }, + }; +}); + +jest.mock('@opensumi/ide-main-layout', () => ({ + IMainLayoutService: Symbol('IMainLayoutService'), +})); + +jest.mock('@opensumi/ide-core-browser/lib/components', () => { + const React = require('react'); + return { + BoxPanel: ({ children }: React.PropsWithChildren) => React.createElement('div', { 'data-box': true }, children), + SplitPanel: ({ + id, + children, + initialResizeOnMount, + }: React.PropsWithChildren<{ id: string; initialResizeOnMount?: boolean }>) => + React.createElement( + 'div', + { 'data-split': id, 'data-initial-resize-on-mount': initialResizeOnMount ? 'true' : 'false' }, + React.Children.toArray(children).map((child: React.ReactElement, index: number) => + React.createElement( + 'div', + { + key: index, + 'data-resize-child': true, + 'data-child-id': child?.props?.id, + 'data-child-slot': child?.props?.slot, + 'data-child-flex': child?.props?.flex, + 'data-child-flex-grow': child?.props?.flexGrow, + 'data-child-min-resize': child?.props?.minResize, + 'data-child-min-size': child?.props?.minSize, + 'data-child-max-resize': child?.props?.maxResize, + }, + child, + ), + ), + ), + getStorageValue: (layoutStorageKey = 'layout') => ({ layout: storedLayouts[layoutStorageKey] || storedLayout }), + }; +}); + +jest.mock('@opensumi/ide-core-browser/lib/layout/constants', () => ({ + DesignLayoutConfig: class DesignLayoutConfig {}, +})); + +jest.mock('../../src/browser/layout/panel-layout.service', () => ({ + AIPanelLayoutService: class AIPanelLayoutService {}, + getPanelLayoutStorageKey: (mode: 'classic' | 'agentic') => (mode === 'agentic' ? 'layout.ai.agentic' : 'layout'), + getAIChatDefaultSize: (mode: 'classic' | 'agentic') => (mode === 'agentic' ? 840 : 360), +})); + +describe('AILayout BDD', () => { + let container: HTMLDivElement; + let root: Root; + + const getSlots = () => + Array.from(container.querySelectorAll('[data-slot]')).map((node) => node.getAttribute('data-slot')); + const getSplitChildIds = (id: string) => + Array.from(container.querySelectorAll(`[data-split="${id}"] > [data-resize-child]`)).map( + (node) => node.getAttribute('data-child-id') || node.getAttribute('data-child-slot'), + ); + const getSplitChildProps = (id: string) => + Array.from(container.querySelectorAll(`[data-split="${id}"] > [data-resize-child]`)).map((node) => ({ + id: node.getAttribute('data-child-id') || node.getAttribute('data-child-slot'), + flex: node.getAttribute('data-child-flex'), + flexGrow: node.getAttribute('data-child-flex-grow'), + minResize: node.getAttribute('data-child-min-resize'), + minSize: node.getAttribute('data-child-min-size'), + maxResize: node.getAttribute('data-child-max-resize'), + })); + const getSlotProps = (slot: string) => { + const node = container.querySelector(`[data-slot="${slot}"]`); + return { + defaultSize: node?.getAttribute('data-default-size'), + maxResize: node?.getAttribute('data-max-resize'), + minResize: node?.getAttribute('data-min-resize'), + minSize: node?.getAttribute('data-min-size'), + }; + }; + const getSplitProps = (id: string) => { + const node = container.querySelector(`[data-split="${id}"]`); + return { + initialResizeOnMount: node?.getAttribute('data-initial-resize-on-mount'), + }; + }; + + beforeEach(() => { + panelLayoutMode = 'classic'; + storedLayout = {}; + storedLayouts = {}; + panelLayoutChangeListener = undefined; + mockToggleSlot.mockClear(); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + }); + + it('Given classic layout, when the shell root renders, then it selects the classic shell', async () => { + const { AIShellRoot } = await import('../../src/browser/layout/ai-layout'); + + act(() => { + root.render(); + }); + + expect(container.querySelector('[data-split="main-horizontal-ai"]')).toBeTruthy(); + expect(container.querySelector('[data-split="main-horizontal-ai-agentic"]')).toBeFalsy(); + }); + + it('Given agentic layout, when the shell root renders, then it selects the agentic shell', async () => { + panelLayoutMode = 'agentic'; + const { AIShellRoot } = await import('../../src/browser/layout/ai-layout'); + + act(() => { + root.render(); + }); + + expect(container.querySelector('[data-split="main-horizontal-ai-agentic"]')).toBeTruthy(); + expect(container.querySelector('[data-split="main-horizontal-ai"]')).toBeFalsy(); + }); + + it('Given the shell root is mounted, when the panel layout changes, then it switches shells without a reload', async () => { + panelLayoutMode = 'agentic'; + const { AIShellRoot } = await import('../../src/browser/layout/ai-layout'); + + act(() => { + root.render(); + }); + + expect(container.querySelector('[data-split="main-horizontal-ai-agentic"]')).toBeTruthy(); + + act(() => { + panelLayoutMode = 'classic'; + panelLayoutChangeListener?.('classic'); + }); + + expect(container.querySelector('[data-split="main-horizontal-ai"]')).toBeTruthy(); + expect(container.querySelector('[data-split="main-horizontal-ai-agentic"]')).toBeFalsy(); + }); + + it('Given classic layout, when it renders, then the workbench appears before AI chat', async () => { + const { AILayout } = await import('../../src/browser/layout/ai-layout'); + + act(() => { + root.render(); + }); + + expect(getSlots()).toEqual(['top', 'view', 'main', 'panel', 'extendView', 'AI-Chat', 'statusBar']); + expect(container.querySelector('[data-split="main-horizontal-ai"]')).toBeTruthy(); + expect(getSplitProps('main-horizontal-ai')).toEqual({ initialResizeOnMount: 'false' }); + expect(getSplitChildIds('main-horizontal-ai')).toEqual(['main-horizontal', 'AI-Chat']); + expect(getSplitChildIds('main-horizontal')).toEqual(['view', 'main-vertical', 'extendView']); + }); + + it('Given classic layout, when dragging the AI split handle, then the workbench is the flex-grow resize target', async () => { + const { AILayout } = await import('../../src/browser/layout/ai-layout'); + + act(() => { + root.render(); + }); + + expect(getSplitChildProps('main-horizontal-ai')).toEqual([ + { id: 'main-horizontal', flex: '1', flexGrow: '1', minResize: null, minSize: null, maxResize: null }, + { id: 'AI-Chat', flex: null, flexGrow: null, minResize: '280', minSize: '0', maxResize: '1080' }, + ]); + }); + + it('Given classic layout has no cached active containers, when it renders, then side slots keep their collapsed defaults', async () => { + const { AILayout } = await import('../../src/browser/layout/ai-layout'); + + act(() => { + root.render(); + }); + + expect(getSlotProps('view')).toEqual({ defaultSize: '49', maxResize: null, minResize: '280', minSize: '49' }); + expect(getSlotProps('extendView')).toEqual({ + defaultSize: '49', + maxResize: null, + minResize: '280', + minSize: '49', + }); + expect(getSlotProps('AI-Chat')).toEqual({ + defaultSize: '0', + maxResize: '1080', + minResize: '280', + minSize: '0', + }); + }); + + it('Given agentic layout, when it renders, then AI chat is before the workbench', async () => { + panelLayoutMode = 'agentic'; + const { AILayout } = await import('../../src/browser/layout/ai-layout'); + + act(() => { + root.render(); + }); + + expect(getSlots()).toEqual(['top', 'AI-Chat', 'main', 'panel', 'view', 'statusBar']); + expect(container.querySelector('[data-split="main-horizontal-ai-agentic"]')).toBeTruthy(); + expect(getSplitProps('main-horizontal-ai-agentic')).toEqual({ initialResizeOnMount: 'false' }); + expect(getSplitChildIds('main-horizontal-ai-agentic')).toEqual(['AI-Chat', 'main-horizontal-agentic']); + expect(getSplitChildIds('main-horizontal-agentic')).toEqual(['main-vertical-agentic', 'view']); + }); + + it('Given agentic layout, when dragging the AI split handle, then the workbench is the flex-grow resize target', async () => { + panelLayoutMode = 'agentic'; + const { AILayout } = await import('../../src/browser/layout/ai-layout'); + + act(() => { + root.render(); + }); + + expect(getSplitChildProps('main-horizontal-ai-agentic')).toEqual([ + { id: 'AI-Chat', flex: null, flexGrow: null, minResize: '640', minSize: '0', maxResize: '1440' }, + { id: 'main-horizontal-agentic', flex: null, flexGrow: '1', minResize: '640', minSize: null, maxResize: null }, + ]); + }); + + it('Given agentic layout, when the workbench renders, then editor stays left of Explorer with a minimum size', async () => { + panelLayoutMode = 'agentic'; + const { AILayout } = await import('../../src/browser/layout/ai-layout'); + + act(() => { + root.render(); + }); + + expect(getSplitChildProps('main-horizontal-agentic')).toEqual([ + { id: 'main-vertical-agentic', flex: null, flexGrow: '1', minResize: '360', minSize: '360', maxResize: null }, + { id: 'view', flex: null, flexGrow: null, minResize: '280', minSize: '49', maxResize: '480' }, + ]); + }); + + it('Given agentic layout has no AI chat cache, when it renders, then AI chat opens with the agentic default size', async () => { + panelLayoutMode = 'agentic'; + const { AILayout } = await import('../../src/browser/layout/ai-layout'); + + act(() => { + root.render(); + }); + + expect(getSlotProps('view')).toEqual({ defaultSize: '49', maxResize: '480', minResize: '280', minSize: '49' }); + expect(container.querySelector('[data-slot="extendView"]')).toBeFalsy(); + expect(getSlotProps('AI-Chat')).toEqual({ + defaultSize: '840', + maxResize: '1440', + minResize: '640', + minSize: '0', + }); + }); + + it('Given agentic layout has oversized side slot cache, when it renders, then Explorer is capped and extend view is omitted', async () => { + panelLayoutMode = 'agentic'; + storedLayout = { + view: { + currentId: 'explorer', + size: 960, + }, + extendView: { + currentId: 'right', + size: 720, + }, + }; + const { AILayout } = await import('../../src/browser/layout/ai-layout'); + + act(() => { + root.render(); + }); + + expect(getSlotProps('view')).toEqual({ defaultSize: '480', maxResize: '480', minResize: '280', minSize: '49' }); + expect(container.querySelector('[data-slot="extendView"]')).toBeFalsy(); + }); + + it('Given agentic layout has cached collapsed AI chat, when it renders, then AI chat opens with the agentic default size', async () => { + panelLayoutMode = 'agentic'; + storedLayout = { + 'AI-Chat': { + currentId: '', + size: 750, + }, + }; + const { AILayout } = await import('../../src/browser/layout/ai-layout'); + + act(() => { + root.render(); + }); + + expect(getSlotProps('AI-Chat')).toEqual({ + defaultSize: '840', + maxResize: '1440', + minResize: '640', + minSize: '0', + }); + }); + + it('Given agentic layout has cached active AI chat, when it renders, then AI chat restores the cached size', async () => { + panelLayoutMode = 'agentic'; + storedLayout = { + 'AI-Chat': { + currentId: 'AI-Chat-Container', + size: 640, + }, + }; + const { AILayout } = await import('../../src/browser/layout/ai-layout'); + + act(() => { + root.render(); + }); + + expect(getSlotProps('AI-Chat')).toEqual({ + defaultSize: '640', + maxResize: '1440', + minResize: '640', + minSize: '0', + }); + }); + + it('Given agentic layout has cached active AI chat without size, when it renders, then AI chat falls back to the agentic default size', async () => { + panelLayoutMode = 'agentic'; + storedLayout = { + 'AI-Chat': { + currentId: 'AI-Chat-Container', + }, + }; + const { AILayout } = await import('../../src/browser/layout/ai-layout'); + + act(() => { + root.render(); + }); + + expect(getSlotProps('AI-Chat')).toEqual({ + defaultSize: '840', + maxResize: '1440', + minResize: '640', + minSize: '0', + }); + }); + + it('Given each panel layout has its own cache, when agentic renders, then it uses the agentic layout cache', async () => { + panelLayoutMode = 'agentic'; + storedLayouts = { + layout: { + 'AI-Chat': { + currentId: 'AI-Chat-Container', + size: 360, + }, + }, + 'layout.ai.agentic': { + 'AI-Chat': { + currentId: 'AI-Chat-Container', + size: 1080, + }, + }, + }; + const { AILayout } = await import('../../src/browser/layout/ai-layout'); + + act(() => { + root.render(); + }); + + expect(getSlotProps('AI-Chat')).toEqual({ + defaultSize: '1080', + maxResize: '1440', + minResize: '640', + minSize: '0', + }); + }); + + it('Given each panel layout has its own cache, when classic renders, then it uses the classic layout cache', async () => { + panelLayoutMode = 'classic'; + storedLayouts = { + layout: { + 'AI-Chat': { + currentId: 'AI-Chat-Container', + size: 360, + }, + }, + 'layout.ai.agentic': { + 'AI-Chat': { + currentId: 'AI-Chat-Container', + size: 1080, + }, + }, + }; + const { AILayout } = await import('../../src/browser/layout/ai-layout'); + + act(() => { + root.render(); + }); + + expect(getSlotProps('AI-Chat')).toEqual({ + defaultSize: '360', + maxResize: '1080', + minResize: '280', + minSize: '0', + }); + }); + + it('Given each panel layout has its own cache, when the direct shells render, then each shell uses its own cache', async () => { + storedLayouts = { + layout: { + 'AI-Chat': { + currentId: 'AI-Chat-Container', + size: 360, + }, + }, + 'layout.ai.agentic': { + 'AI-Chat': { + currentId: 'AI-Chat-Container', + size: 1080, + }, + }, + }; + const { AgenticShell, ClassicShell } = await import('../../src/browser/layout/ai-layout'); + + act(() => { + root.render(); + }); + + expect(getSlotProps('AI-Chat')).toEqual({ + defaultSize: '360', + maxResize: '1080', + minResize: '280', + minSize: '0', + }); + + act(() => { + root.render(); + }); + + expect(getSlotProps('AI-Chat')).toEqual({ + defaultSize: '1080', + maxResize: '1440', + minResize: '640', + minSize: '0', + }); + }); +}); diff --git a/packages/ai-native/__test__/browser/ai-tabbar-layout.test.tsx b/packages/ai-native/__test__/browser/ai-tabbar-layout.test.tsx new file mode 100644 index 0000000000..e9162fcdb2 --- /dev/null +++ b/packages/ai-native/__test__/browser/ai-tabbar-layout.test.tsx @@ -0,0 +1,499 @@ +import React from 'react'; +import { Root, createRoot } from 'react-dom/client'; +import { act } from 'react-dom/test-utils'; + +let panelLayoutMode: 'classic' | 'agentic' = 'classic'; +let mockCapturedDesignLeftProps: any; +let mockCapturedTabRendererProps: any; +let mockCapturedLeftTabbarProps: any; +let mockCapturedTabbarViewBaseProps: any; +let mockCapturedResizeHandle: any; +let mockViewCurrentContainerId = 'view-current'; +let mockExtendViewCurrentContainerId = 'extend-view-current'; +let mockViewReadyPromise: Promise = Promise.resolve(); + +const mockMainLayoutServiceToken = Symbol('IMainLayoutService'); +const mockTabbarServiceFactoryToken = Symbol('TabbarServiceFactory'); +const mockViewTabbarService = { + currentContainerId: { + get: jest.fn(() => mockViewCurrentContainerId), + }, + visibleContainers: [] as any[], + prevSize: undefined as number | undefined, + viewReady: { + get promise() { + return mockViewReadyPromise; + }, + }, +}; +const mockExtendViewTabbarService = { + currentContainerId: { + get: jest.fn(() => mockExtendViewCurrentContainerId), + }, + visibleContainers: [] as any[], +}; +const mockTabbarServices = { + view: mockViewTabbarService, + extendView: mockExtendViewTabbarService, +}; +const mockTabbarServiceFactory = jest.fn((side: keyof typeof mockTabbarServices) => mockTabbarServices[side]); + +jest.mock('@opensumi/ide-core-browser', () => ({ + SlotLocation: { + view: 'view', + extendView: 'extendView', + }, + fastdom: { + measureAtNextFrame: (callback: () => void) => { + callback(); + return { dispose: jest.fn() }; + }, + }, + useAutorun: (value: any) => (typeof value?.get === 'function' ? value.get() : value), + useContextMenus: () => [[]], + useInjectable: (token: any) => { + if (token?.name === 'AIPanelLayoutService') { + return { + getLayoutMode: () => panelLayoutMode, + }; + } + if (token === mockMainLayoutServiceToken) { + return { + getExtraMenu: () => [], + getExtraTopMenu: () => [], + }; + } + if (token === mockTabbarServiceFactoryToken) { + return mockTabbarServiceFactory; + } + return {}; + }, +})); + +jest.mock('@opensumi/ide-core-browser/lib/components', () => { + const React = require('react'); + return { + EDirection: { + LeftToRight: 'left-to-right', + RightToLeft: 'right-to-left', + }, + PanelContext: React.createContext({ + setSize: jest.fn(), + setRelativeSize: jest.fn(), + getSize: jest.fn(), + getRelativeSize: jest.fn(), + lockSize: jest.fn(), + setMaxSize: jest.fn(), + hidePanel: jest.fn(), + }), + }; +}); + +jest.mock('@opensumi/ide-core-browser/lib/components/ai-native', () => ({ + EnhanceIcon: () => , + EnhanceIconWithCtxMenu: () => , + EnhancePopover: ({ children }: React.PropsWithChildren) => <>{children}, + HorizontalVertical: () => , +})); + +jest.mock('@opensumi/ide-core-browser/lib/common/container-id', () => ({ + EXPLORER_CONTAINER_ID: 'explorer', + SCM_CONTAINER_ID: 'scm', +})); + +jest.mock('@opensumi/ide-core-browser/lib/layout/constants', () => ({ + DesignLayoutConfig: class DesignLayoutConfig {}, +})); + +jest.mock('@opensumi/ide-core-browser/lib/layout/view-id', () => ({ + VIEW_CONTAINERS: { + LEFT_TABBAR_PANEL: 'left-tabbar-panel', + }, +})); + +jest.mock('@opensumi/ide-core-common', () => ({ + localize: (key: string) => key, +})); + +jest.mock('@opensumi/ide-design/lib/browser/layout/tabbar.view', () => ({ + DesignLeftTabRenderer: (props: any) => { + const TabbarView = props.tabbarView; + mockCapturedDesignLeftProps = props; + return
{TabbarView ? : null}
; + }, + DesignRightTabRenderer: () =>
, +})); + +jest.mock('@opensumi/ide-main-layout', () => ({ + IMainLayoutService: mockMainLayoutServiceToken, +})); + +jest.mock('@opensumi/ide-main-layout/lib/browser/tabbar/bar.view', () => ({ + ChatTabbarRenderer2: () =>
, + IconElipses: () =>
, + IconTabView: () =>
, + LeftTabbarRenderer: (props: any) => { + mockCapturedLeftTabbarProps = props; + return
; + }, + RightTabbarRenderer: () =>
, + TabbarViewBase: (props: any) => { + mockCapturedTabbarViewBaseProps = props; + return
; + }, +})); + +jest.mock('@opensumi/ide-main-layout/lib/browser/tabbar/panel.view', () => ({ + BaseTabPanelView: () =>
, + ContainerView: () =>
, +})); + +jest.mock('@opensumi/ide-main-layout/lib/browser/tabbar/renderer.view', () => ({ + TabRendererBase: (props: any) => { + const React = require('react'); + const { PanelContext } = require('@opensumi/ide-core-browser/lib/components'); + mockCapturedTabRendererProps = props; + mockCapturedResizeHandle = React.useContext(PanelContext); + const TabbarView = props.TabbarView; + const TabpanelView = props.TabpanelView; + return ( +
+ + +
+ ); + }, +})); + +jest.mock('@opensumi/ide-main-layout/lib/browser/tabbar/tabbar.service', () => ({ + TabbarServiceFactory: mockTabbarServiceFactoryToken, +})); + +jest.mock('../../src/browser/layout/panel-layout.service', () => ({ + AIPanelLayoutService: class AIPanelLayoutService {}, +})); + +describe('AI tabbar layout BDD', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + panelLayoutMode = 'classic'; + mockCapturedDesignLeftProps = undefined; + mockCapturedTabRendererProps = undefined; + mockCapturedLeftTabbarProps = undefined; + mockCapturedTabbarViewBaseProps = undefined; + mockCapturedResizeHandle = undefined; + mockTabbarServiceFactory.mockClear(); + mockViewCurrentContainerId = 'view-current'; + mockViewReadyPromise = Promise.resolve(); + mockViewTabbarService.visibleContainers = []; + mockViewTabbarService.prevSize = undefined; + mockExtendViewCurrentContainerId = 'extend-view-current'; + mockExtendViewTabbarService.visibleContainers = []; + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + }); + + it('Given classic layout, when the left tab renderer renders, then it uses the design left renderer', async () => { + const { AILeftTabRenderer } = await import('../../src/browser/layout/tabbar.view'); + + act(() => { + root.render(); + }); + + expect(container.querySelector('[data-testid="design-left-tab-renderer"]')).toBeTruthy(); + expect(mockCapturedDesignLeftProps.className).toBe('slot-class'); + expect(mockCapturedDesignLeftProps.tabbarView).toBeTruthy(); + expect(mockCapturedLeftTabbarProps.tabbarViewProps).toBeUndefined(); + expect(mockCapturedTabRendererProps).toBeUndefined(); + }); + + it('Given agentic layout, when the left tab renderer renders, then it puts the view tabbar on the right', async () => { + panelLayoutMode = 'agentic'; + const { AILeftTabRenderer } = await import('../../src/browser/layout/tabbar.view'); + + act(() => { + root.render(); + }); + + expect(container.querySelector('[data-testid="tab-renderer-base"]')).toBeTruthy(); + expect(mockCapturedTabRendererProps.side).toBe('view'); + expect(mockCapturedTabRendererProps.direction).toBe('right-to-left'); + expect(mockCapturedTabRendererProps.className).toContain('left-slot'); + expect(mockCapturedTabRendererProps.className).toContain('design_left_slot'); + expect(mockCapturedTabRendererProps.className).toContain('agentic_view_slot'); + expect(container.querySelector('.agentic_view_tab_bar')).toBeTruthy(); + expect(mockCapturedLeftTabbarProps).toBeTruthy(); + expect(mockTabbarServiceFactory).toHaveBeenCalledWith('view'); + expect(mockTabbarServiceFactory).not.toHaveBeenCalledWith('extendView'); + }); + + it('Given agentic layout, when rendering side entries, then it allows only Explorer and SCM', async () => { + panelLayoutMode = 'agentic'; + mockViewTabbarService.visibleContainers = [ + { + options: { + containerId: 'explorer', + }, + }, + { + options: { + containerId: 'search', + }, + }, + { + options: { + containerId: 'scm', + }, + }, + { + options: { + containerId: 'debug', + }, + }, + { + options: { + containerId: 'extension', + }, + }, + ]; + const { AILeftTabRenderer } = await import('../../src/browser/layout/tabbar.view'); + + act(() => { + root.render(); + }); + + const containerFilter = mockCapturedLeftTabbarProps.tabbarViewProps.containerFilter; + expect( + mockViewTabbarService.visibleContainers.filter(containerFilter).map((container) => container.options.containerId), + ).toEqual(['explorer', 'scm']); + expect(mockCapturedLeftTabbarProps.renderOtherVisibleContainers).toBeUndefined(); + }); + + it('Given agentic layout, when the view slot restores size, then it uses the previous resize handle', async () => { + panelLayoutMode = 'agentic'; + const { PanelContext } = await import('@opensumi/ide-core-browser/lib/components'); + const { AILeftTabRenderer } = await import('../../src/browser/layout/tabbar.view'); + const parentResizeHandle = { + setSize: jest.fn(), + setRelativeSize: jest.fn(), + getSize: jest.fn(() => 384), + getRelativeSize: jest.fn(() => [1, 2]), + lockSize: jest.fn(), + setMaxSize: jest.fn(), + hidePanel: jest.fn(), + }; + + act(() => { + root.render( + + + , + ); + }); + + mockCapturedResizeHandle.setSize(384, false); + mockCapturedResizeHandle.setRelativeSize(1, 2, false); + mockCapturedResizeHandle.getSize(false); + mockCapturedResizeHandle.getRelativeSize(false); + mockCapturedResizeHandle.lockSize(true, false); + mockCapturedResizeHandle.setMaxSize(true, false); + + expect(parentResizeHandle.setSize).toHaveBeenCalledWith(384, true); + expect(parentResizeHandle.setRelativeSize).toHaveBeenCalledWith(1, 2, true); + expect(parentResizeHandle.getSize).toHaveBeenCalledWith(true); + expect(parentResizeHandle.getRelativeSize).toHaveBeenCalledWith(true); + expect(parentResizeHandle.lockSize).toHaveBeenCalledWith(true, true); + expect(parentResizeHandle.setMaxSize).toHaveBeenCalledWith(true, true); + }); + + it('Given agentic layout has an active Explorer at activity bar width, when view is ready, then it restores cached width', async () => { + panelLayoutMode = 'agentic'; + mockViewCurrentContainerId = 'workbench.explorer.fileView'; + mockViewTabbarService.prevSize = 384; + const { PanelContext } = await import('@opensumi/ide-core-browser/lib/components'); + const { AILeftTabRenderer } = await import('../../src/browser/layout/tabbar.view'); + const parentResizeHandle = { + setSize: jest.fn(), + setRelativeSize: jest.fn(), + getSize: jest.fn(() => 49), + getRelativeSize: jest.fn(() => [951, 49]), + lockSize: jest.fn(), + setMaxSize: jest.fn(), + hidePanel: jest.fn(), + }; + + await act(async () => { + root.render( + + + , + ); + await Promise.resolve(); + }); + + expect(parentResizeHandle.getSize).toHaveBeenCalledWith(true); + expect(parentResizeHandle.setSize).toHaveBeenCalledWith(384, true); + }); + + it('Given agentic layout has an active Explorer without cached width, when view is ready, then it restores the default usable width', async () => { + panelLayoutMode = 'agentic'; + mockViewCurrentContainerId = 'workbench.explorer.fileView'; + const { PanelContext } = await import('@opensumi/ide-core-browser/lib/components'); + const { AILeftTabRenderer } = await import('../../src/browser/layout/tabbar.view'); + const parentResizeHandle = { + setSize: jest.fn(), + setRelativeSize: jest.fn(), + getSize: jest.fn(() => 49), + getRelativeSize: jest.fn(() => [951, 49]), + lockSize: jest.fn(), + setMaxSize: jest.fn(), + hidePanel: jest.fn(), + }; + + await act(async () => { + root.render( + + + , + ); + await Promise.resolve(); + }); + + expect(parentResizeHandle.setSize).toHaveBeenCalledWith(310, true); + }); + + it('Given agentic layout has an active Explorer before view is ready, when it is collapsed, then it restores immediately', async () => { + panelLayoutMode = 'agentic'; + mockViewCurrentContainerId = 'workbench.explorer.fileView'; + mockViewReadyPromise = new Promise(() => {}); + const { PanelContext } = await import('@opensumi/ide-core-browser/lib/components'); + const { AILeftTabRenderer } = await import('../../src/browser/layout/tabbar.view'); + const parentResizeHandle = { + setSize: jest.fn(), + setRelativeSize: jest.fn(), + getSize: jest.fn(() => 49), + getRelativeSize: jest.fn(() => [951, 49]), + lockSize: jest.fn(), + setMaxSize: jest.fn(), + hidePanel: jest.fn(), + }; + + await act(async () => { + root.render( + + + , + ); + await Promise.resolve(); + }); + + expect(parentResizeHandle.setSize).toHaveBeenCalledWith(310, true); + }); + + it('Given classic layout, when the hidden AI chat renderer renders, then it keeps the main branch direction', async () => { + const { AIChatTabRenderer } = await import('../../src/browser/layout/tabbar.view'); + + act(() => { + root.render(); + }); + + expect(mockCapturedTabRendererProps.direction).toBe('left-to-right'); + expect(mockCapturedTabRendererProps.className).not.toContain('design_right_slot'); + expect(mockCapturedTabbarViewBaseProps.disableAutoAdjust).toBeUndefined(); + }); + + it('Given classic layout, when AI chat collapses, then it uses the latter split child resize side', async () => { + const { PanelContext } = await import('@opensumi/ide-core-browser/lib/components'); + const { AIChatTabRenderer } = await import('../../src/browser/layout/tabbar.view'); + const parentResizeHandle = { + setSize: jest.fn(), + setRelativeSize: jest.fn(), + getSize: jest.fn(() => 360), + getRelativeSize: jest.fn(() => [1000, 360]), + lockSize: jest.fn(), + setMaxSize: jest.fn(), + hidePanel: jest.fn(), + }; + + act(() => { + root.render( + + + , + ); + }); + + mockCapturedResizeHandle.setSize(0, false); + mockCapturedResizeHandle.getSize(false); + + expect(parentResizeHandle.setSize).toHaveBeenCalledWith(0, true); + expect(parentResizeHandle.getSize).toHaveBeenCalledWith(true); + }); + + it('Given classic layout, when the tabbed AI chat renderer renders, then it keeps the main branch right-side direction', async () => { + const { AIChatTabRendererWithTab } = await import('../../src/browser/layout/tabbar.view'); + + act(() => { + root.render(); + }); + + expect(mockCapturedTabRendererProps.direction).toBe('right-to-left'); + expect(mockCapturedTabRendererProps.className).toContain('design_right_slot'); + }); + + it('Given agentic layout, when AI chat restores size, then it uses the first split child resize side', async () => { + panelLayoutMode = 'agentic'; + const { PanelContext } = await import('@opensumi/ide-core-browser/lib/components'); + const { AIChatTabRenderer } = await import('../../src/browser/layout/tabbar.view'); + const parentResizeHandle = { + setSize: jest.fn(), + setRelativeSize: jest.fn(), + getSize: jest.fn(() => 840), + getRelativeSize: jest.fn(() => [840, 1000]), + lockSize: jest.fn(), + setMaxSize: jest.fn(), + hidePanel: jest.fn(), + }; + + act(() => { + root.render( + + + , + ); + }); + + mockCapturedResizeHandle.setSize(840, true); + mockCapturedResizeHandle.getSize(true); + + expect(mockCapturedTabRendererProps.direction).toBe('left-to-right'); + expect(parentResizeHandle.setSize).toHaveBeenCalledWith(840, false); + expect(parentResizeHandle.getSize).toHaveBeenCalledWith(false); + }); + + it('Given agentic layout, when the hidden AI chat tabbar renders, then it does not render overflow tabs', async () => { + panelLayoutMode = 'agentic'; + const { AIChatTabRenderer } = await import('../../src/browser/layout/tabbar.view'); + + act(() => { + root.render(); + }); + + expect(mockCapturedTabbarViewBaseProps).toMatchObject({ + barSize: 0, + panelBorderSize: 0, + tabSize: 0, + disableAutoAdjust: true, + }); + }); +}); diff --git a/packages/ai-native/__test__/browser/avatar.view.test.tsx b/packages/ai-native/__test__/browser/avatar.view.test.tsx new file mode 100644 index 0000000000..23f5f2fd65 --- /dev/null +++ b/packages/ai-native/__test__/browser/avatar.view.test.tsx @@ -0,0 +1,170 @@ +import React from 'react'; +import { Root, createRoot } from 'react-dom/client'; +import { Simulate, act } from 'react-dom/test-utils'; + +import { AIChatLogoAvatar } from '../../src/browser/layout/view/avatar/avatar.view'; + +const mockToggleAIChatView = jest.fn(); +const mockSetLayoutMode = jest.fn(); +const mockGetLayoutMode = jest.fn(() => 'agentic'); +const layoutChangeListeners: Array<(mode: string) => void> = []; +const mockOnDidChangePanelLayout = jest.fn((listener: (mode: string) => void) => { + layoutChangeListeners.push(listener); + return { + dispose: () => { + const idx = layoutChangeListeners.indexOf(listener); + if (idx >= 0) { + layoutChangeListeners.splice(idx, 1); + } + }, + }; +}); + +jest.mock('@opensumi/ide-core-browser', () => ({ + localize: (_key: string, defaultValue?: string) => defaultValue || _key, + useInjectable: (token: any) => { + if (token?.name === 'AIPanelLayoutService') { + return { + getLayoutMode: mockGetLayoutMode, + setLayoutMode: mockSetLayoutMode, + toggleAIChatView: mockToggleAIChatView, + onDidChangePanelLayout: mockOnDidChangePanelLayout, + }; + } + return {}; + }, +})); + +jest.mock('@opensumi/ide-components', () => { + const React = require('react'); + return { + Select: ({ value, onChange, options }: any) => + React.createElement( + 'select', + { + 'data-testid': 'layout-select', + value, + onChange: (event: React.ChangeEvent) => onChange?.(event.target.value), + }, + (options || []).map((option: { label: string; value: string }) => + React.createElement('option', { key: option.value, value: option.value }, option.label), + ), + ), + }; +}); + +jest.mock('@opensumi/ide-core-browser/lib/components/ai-native', () => { + const React = require('react'); + return { + AILogoAvatar: ({ iconClassName }: any) => + React.createElement('span', { + 'data-testid': 'ai-logo-avatar', + className: iconClassName, + }), + }; +}); + +jest.mock('../../src/browser/layout/panel-layout.service', () => ({ + AIPanelLayoutService: class AIPanelLayoutService {}, + getAIChatDefaultSize: (mode: string) => (mode === 'agentic' ? 840 : 360), +})); + +jest.mock('../../src/browser/layout/view/avatar/avatar.module.less', () => ({ + ai_actions: 'ai_actions', + ai_switch: 'ai_switch', + avatar_icon_large: 'avatar_icon_large', + layout_switch: 'layout_switch', +})); + +describe('AIChatLogoAvatar', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + mockGetLayoutMode.mockReturnValue('agentic'); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + layoutChangeListeners.length = 0; + jest.clearAllMocks(); + }); + + function renderAvatar(): void { + act(() => { + root.render(); + }); + } + + it('renders the layout select with the current mode', () => { + renderAvatar(); + + const select = container.querySelector('[data-testid="layout-select"]'); + expect(select).not.toBeNull(); + expect(select!.value).toBe('agentic'); + const options = Array.from(select!.querySelectorAll('option')).map((option) => option.value); + expect(options).toEqual(['agentic', 'classic']); + }); + + it('clicks the AI icon without changing layout mode', () => { + renderAvatar(); + + const aiLogoAvatar = container.querySelector('[data-testid="ai-logo-avatar"]'); + expect(aiLogoAvatar).not.toBeNull(); + + act(() => { + Simulate.click(aiLogoAvatar!.parentElement as Element); + }); + + expect(mockToggleAIChatView).toHaveBeenCalledWith('agentic'); + expect(mockSetLayoutMode).not.toHaveBeenCalled(); + }); + + it('toggles the AI chat with the classic layout mode', () => { + mockGetLayoutMode.mockReturnValue('classic'); + renderAvatar(); + + const aiLogoAvatar = container.querySelector('[data-testid="ai-logo-avatar"]'); + expect(aiLogoAvatar).not.toBeNull(); + + act(() => { + Simulate.click(aiLogoAvatar!.parentElement as Element); + }); + + expect(mockToggleAIChatView).toHaveBeenCalledWith('classic'); + }); + + it('calls setLayoutMode when the select value changes', () => { + renderAvatar(); + + const select = container.querySelector('[data-testid="layout-select"]'); + expect(select).not.toBeNull(); + + act(() => { + select!.value = 'classic'; + Simulate.change(select!); + }); + + expect(mockSetLayoutMode).toHaveBeenCalledWith('classic'); + expect(mockToggleAIChatView).not.toHaveBeenCalled(); + }); + + it('reflects layout mode changes emitted by the service', () => { + mockGetLayoutMode.mockReturnValueOnce('agentic'); + renderAvatar(); + + act(() => { + mockGetLayoutMode.mockReturnValue('classic'); + layoutChangeListeners.forEach((listener) => listener('classic')); + }); + + const select = container.querySelector('[data-testid="layout-select"]'); + expect(select!.value).toBe('classic'); + }); +}); diff --git a/packages/ai-native/__test__/browser/chat/acp-chat-input-validation.test.ts b/packages/ai-native/__test__/browser/chat/acp-chat-input-validation.test.ts new file mode 100644 index 0000000000..505aa55ac2 --- /dev/null +++ b/packages/ai-native/__test__/browser/chat/acp-chat-input-validation.test.ts @@ -0,0 +1,22 @@ +import { hasAcpChatSendPayload } from '../../../src/browser/components/acp/chat-input-validation'; + +describe('ACP chat input validation', () => { + it('rejects plain whitespace-only prompts', () => { + expect(hasAcpChatSendPayload({ message: '' })).toBe(false); + expect(hasAcpChatSendPayload({ message: ' \n\t ' })).toBe(false); + expect(hasAcpChatSendPayload({ message: '\u00a0\u200b' })).toBe(false); + }); + + it('rejects contenteditable blank markup', () => { + expect(hasAcpChatSendPayload({ message: '
' })).toBe(false); + expect(hasAcpChatSendPayload({ message: '

  ' })).toBe(false); + }); + + it('keeps text, context chips, commands, and attachments valid', () => { + expect(hasAcpChatSendPayload({ message: 'hello\nworld' })).toBe(true); + expect(hasAcpChatSendPayload({ message: '{{@file:/workspace/editor.js}}' })).toBe(true); + expect(hasAcpChatSendPayload({ message: '' })).toBe(true); + expect(hasAcpChatSendPayload({ message: ' ', command: 'generate' })).toBe(true); + expect(hasAcpChatSendPayload({ message: ' ', images: ['data:image/png;base64,a'] })).toBe(true); + }); +}); diff --git a/packages/ai-native/__test__/browser/chat/acp-chat-internal.service.test.ts b/packages/ai-native/__test__/browser/chat/acp-chat-internal.service.test.ts new file mode 100644 index 0000000000..cae9dfb173 --- /dev/null +++ b/packages/ai-native/__test__/browser/chat/acp-chat-internal.service.test.ts @@ -0,0 +1,445 @@ +import { Emitter } from '@opensumi/ide-core-common'; + +import { ChatModel } from '../../../src/browser/chat/chat-model'; +import { ChatFeatureRegistry } from '../../../src/browser/chat/chat.feature.registry'; +import { + AcpChatInternalService, + formatAcpLoadSessionFallbackMessage, +} from '../../../src/browser/chat/chat.internal.service.acp'; + +const disposable = () => ({ dispose: jest.fn() }); + +describe('AcpChatInternalService', () => { + it('notifies current session model and mode listeners when ACP session state changes', () => { + const service = new AcpChatInternalService() as any; + const stateEmitter = new Emitter(); + const model = new ChatModel(new ChatFeatureRegistry(), { + sessionId: 'acp:sess-1', + currentModeId: 'code', + }); + const sessionModelChanges: any[] = []; + const modeChanges: string[] = []; + + Object.defineProperty(service, 'chatManagerService', { + value: { + onDidApplySessionState: stateEmitter.event, + onStorageInit: jest.fn(() => ({ dispose: jest.fn() })), + }, + }); + Object.defineProperty(service, 'aiNativeConfigService', { + value: { capabilities: { supportsAgentMode: true } }, + }); + service._sessionModel = model; + service.onSessionModelChange((sessionModel) => sessionModelChanges.push(sessionModel)); + service.onModeChange((modeId) => modeChanges.push(modeId)); + + service.init(); + stateEmitter.fire({ + sessionId: 'acp:sess-1', + model, + previousModeId: 'plan', + currentModeId: 'code', + }); + + expect(sessionModelChanges).toEqual([model]); + expect(modeChanges).toEqual(['code']); + }); + + it('notifies session model listeners for non-mode ACP session state changes', () => { + const service = new AcpChatInternalService() as any; + const stateEmitter = new Emitter(); + const model = new ChatModel(new ChatFeatureRegistry(), { + sessionId: 'acp:sess-1', + currentModeId: 'code', + }); + const sessionModelChanges: any[] = []; + const modeChanges: string[] = []; + + Object.defineProperty(service, 'chatManagerService', { + value: { + onDidApplySessionState: stateEmitter.event, + onStorageInit: jest.fn(() => ({ dispose: jest.fn() })), + }, + }); + Object.defineProperty(service, 'aiNativeConfigService', { + value: { capabilities: { supportsAgentMode: true } }, + }); + service._sessionModel = model; + service.onSessionModelChange((sessionModel) => sessionModelChanges.push(sessionModel)); + service.onModeChange((modeId) => modeChanges.push(modeId)); + + service.init(); + stateEmitter.fire({ + sessionId: 'acp:sess-1', + model, + previousModeId: 'code', + currentModeId: 'code', + }); + + expect(sessionModelChanges).toEqual([model]); + expect(modeChanges).toEqual([]); + }); + + describe('draft session lifecycle', () => { + function createService() { + const service = new AcpChatInternalService() as any; + const model = new ChatModel(new ChatFeatureRegistry(), { + sessionId: 'acp:sess-1', + modelId: 'model-a', + agentModels: [ + { + modelId: 'model-a', + name: 'Model A', + }, + ], + agentModes: [ + { + id: 'code', + name: 'Code', + }, + ], + currentModeId: 'code', + configOptions: [ + { + id: 'approval', + name: 'Approval', + currentValue: 'default', + options: [ + { value: 'default', label: 'Default' }, + { value: 'always', label: 'Always' }, + ], + }, + ], + }); + const stateEmitter = new Emitter(); + const chatManagerService = { + clearSession: jest.fn(), + getAvailableCommands: jest.fn(() => [{ name: 'help', description: 'Help' }]), + getSession: jest.fn(() => model), + getSessions: jest.fn(() => [model]), + loadSession: jest.fn(() => Promise.resolve()), + onDidApplySessionState: stateEmitter.event, + onStorageInit: jest.fn(() => disposable()), + startSession: jest.fn(() => Promise.resolve(model)), + }; + const permissionBridgeService = { + clearSessionDialogs: jest.fn(), + setActiveSession: jest.fn(), + }; + const messageService = { + error: jest.fn(), + info: jest.fn(), + }; + const aiBackService = { + setSessionConfigOption: jest.fn(() => Promise.resolve()), + setSessionMode: jest.fn(() => Promise.resolve()), + setSessionModel: jest.fn(() => Promise.resolve()), + }; + + Object.defineProperty(service, 'chatManagerService', { + value: chatManagerService, + }); + Object.defineProperty(service, 'permissionBridgeService', { + value: permissionBridgeService, + }); + Object.defineProperty(service, 'messageService', { + value: messageService, + }); + Object.defineProperty(service, 'aiBackService', { + value: aiBackService, + }); + Object.defineProperty(service, 'aiNativeConfigService', { + value: { capabilities: { supportsAgentMode: true } }, + }); + Object.defineProperty(service, 'logger', { + value: { error: jest.fn(), log: jest.fn(), warn: jest.fn() }, + }); + + return { + chatManagerService, + aiBackService, + messageService, + model, + permissionBridgeService, + service, + }; + } + + it('reuses the active ACP session when ensuring a session model', async () => { + const { chatManagerService, model, service } = createService(); + service._sessionModel = model; + + await expect(service.ensureSessionModel()).resolves.toBe(model); + + expect(chatManagerService.startSession).not.toHaveBeenCalled(); + }); + + it('creates one bootstrap ACP session and exposes its footer metadata', async () => { + const { chatManagerService, model, permissionBridgeService, service } = createService(); + + await expect(service.ensureBootstrapSessionModel()).resolves.toBe(model); + await expect(service.ensureBootstrapSessionModel()).resolves.toBe(model); + + expect(chatManagerService.startSession).toHaveBeenCalledTimes(1); + expect(permissionBridgeService.setActiveSession).toHaveBeenCalledWith('sess-1'); + expect(service.sessionModel).toBe(model); + expect(service.getAvailableCommands()).toEqual([{ name: 'help', description: 'Help' }]); + expect(service.getDraftSessionState()).toEqual({ + agentModes: model.agentModes, + currentModeId: 'code', + agentModels: model.agentModels, + modelId: 'model-a', + configOptions: model.configOptions, + }); + }); + + it('reuses the bootstrap ACP session on first send instead of creating another session', async () => { + const { chatManagerService, model, service } = createService(); + + await expect(service.ensureBootstrapSessionModel()).resolves.toBe(model); + await expect(service.ensureSessionModel()).resolves.toBe(model); + + expect(chatManagerService.startSession).toHaveBeenCalledTimes(1); + }); + + it('hides an unused bootstrap session from visible history until it receives user content', async () => { + const { model, service } = createService(); + + await service.ensureBootstrapSessionModel(); + + expect(service.getSessions()).toEqual([model]); + expect(service.getVisibleSessions()).toEqual([]); + + model.history.addUserMessage({ + content: 'hello', + agentId: 'default-agent', + agentCommand: '', + images: [], + relationId: 'request-1', + }); + + expect(service.getVisibleSessions()).toEqual([model]); + }); + + it('keeps an unused bootstrap session active when starting a new chat', async () => { + const { chatManagerService, model, permissionBridgeService, service } = createService(); + + await service.ensureBootstrapSessionModel(); + permissionBridgeService.setActiveSession.mockClear(); + + service.enterDraftSession(); + + expect(service.sessionModel).toBe(model); + expect(chatManagerService.startSession).toHaveBeenCalledTimes(1); + expect(permissionBridgeService.setActiveSession).not.toHaveBeenCalledWith(undefined); + }); + + it('keeps later new chat lazy after the bootstrap session has been used', async () => { + const { chatManagerService, model, service } = createService(); + const nextModel = new ChatModel(new ChatFeatureRegistry(), { + sessionId: 'acp:sess-2', + }); + chatManagerService.startSession.mockReset(); + chatManagerService.startSession.mockResolvedValueOnce(model).mockResolvedValueOnce(nextModel); + + await service.ensureBootstrapSessionModel(); + model.history.addUserMessage({ + content: 'hello', + agentId: 'default-agent', + agentCommand: '', + images: [], + relationId: 'request-1', + }); + + service.enterDraftSession(); + + expect(service.sessionModel).toBeUndefined(); + expect(chatManagerService.startSession).toHaveBeenCalledTimes(1); + + await expect(service.ensureSessionModel()).resolves.toBe(nextModel); + expect(chatManagerService.startSession).toHaveBeenCalledTimes(2); + }); + + it('does not block first-send lazy session creation when bootstrap creation fails', async () => { + const { chatManagerService, model, service } = createService(); + chatManagerService.startSession.mockReset(); + chatManagerService.startSession.mockRejectedValueOnce(new Error('session/new failed')); + chatManagerService.startSession.mockResolvedValueOnce(model); + + await expect(service.ensureBootstrapSessionModel()).resolves.toBeUndefined(); + await expect(service.ensureSessionModel()).resolves.toBe(model); + + expect(chatManagerService.startSession).toHaveBeenCalledTimes(2); + }); + + it('creates the ACP session only when ensuring from draft', async () => { + const { chatManagerService, model, permissionBridgeService, service } = createService(); + const sessionModelChanges: any[] = []; + const availableCommandsChanges: any[] = []; + const sessionChanges: string[] = []; + const loadingChanges: boolean[] = []; + + service.onSessionModelChange((sessionModel) => sessionModelChanges.push(sessionModel)); + service.onAvailableCommandsChange((commands) => availableCommandsChanges.push(commands)); + service.onChangeSession((sessionId) => sessionChanges.push(sessionId)); + service.onSessionLoadingChange((loading) => loadingChanges.push(loading)); + + await expect(service.ensureSessionModel()).resolves.toBe(model); + + expect(chatManagerService.startSession).toHaveBeenCalledTimes(1); + expect(permissionBridgeService.setActiveSession).toHaveBeenCalledWith('sess-1'); + expect(sessionModelChanges).toEqual([model]); + expect(availableCommandsChanges).toEqual([[{ name: 'help', description: 'Help' }]]); + expect(sessionChanges).toEqual(['acp:sess-1']); + expect(loadingChanges).toEqual([true, false]); + }); + + it('reuses the in-flight ACP session creation request', async () => { + const { chatManagerService, model, service } = createService(); + const sessionModelChanges: any[] = []; + const loadingChanges: boolean[] = []; + let resolveStartSession!: (model: ChatModel) => void; + + chatManagerService.startSession.mockImplementation( + () => + new Promise((resolve) => { + resolveStartSession = resolve; + }), + ); + service.onSessionModelChange((sessionModel) => sessionModelChanges.push(sessionModel)); + service.onSessionLoadingChange((loading) => loadingChanges.push(loading)); + + const first = service.ensureSessionModel(); + const second = service.ensureSessionModel(); + + expect(chatManagerService.startSession).toHaveBeenCalledTimes(1); + + resolveStartSession(model); + + await expect(Promise.all([first, second])).resolves.toEqual([model, model]); + expect(sessionModelChanges).toEqual([model]); + expect(loadingChanges).toEqual([true, false]); + }); + + it('deduplicates concurrent ACP createSessionModel calls', async () => { + const { chatManagerService, model, service } = createService(); + const sessionModelChanges: any[] = []; + const loadingChanges: boolean[] = []; + let resolveStartSession!: (model: ChatModel) => void; + + chatManagerService.startSession.mockImplementation( + () => + new Promise((resolve) => { + resolveStartSession = resolve; + }), + ); + service.onSessionModelChange((sessionModel) => sessionModelChanges.push(sessionModel)); + service.onSessionLoadingChange((loading) => loadingChanges.push(loading)); + + const first = service.createSessionModel(); + const second = service.createSessionModel(); + + expect(chatManagerService.startSession).toHaveBeenCalledTimes(1); + + resolveStartSession(model); + await Promise.all([first, second]); + + expect(sessionModelChanges).toEqual([model]); + expect(loadingChanges).toEqual([true, false]); + }); + + it('enters draft and preserves ACP footer state for the next input', () => { + const { model, permissionBridgeService, service } = createService(); + const sessionModelChanges: any[] = []; + const availableCommandsChanges: any[] = []; + const modeChanges: string[] = []; + const sessionChanges: string[] = []; + service._sessionModel = model; + service.setAvailableCommands([{ name: 'help', description: 'Help' }]); + + service.onSessionModelChange((sessionModel) => sessionModelChanges.push(sessionModel)); + service.onAvailableCommandsChange((commands) => availableCommandsChanges.push(commands)); + service.onModeChange((modeId) => modeChanges.push(modeId)); + service.onChangeSession((sessionId) => sessionChanges.push(sessionId)); + + service.enterDraftSession(); + + expect(service.sessionModel).toBeUndefined(); + expect(service.getDraftSessionState()).toEqual({ + agentModes: model.agentModes, + currentModeId: 'code', + agentModels: model.agentModels, + modelId: 'model-a', + configOptions: model.configOptions, + }); + expect(service.getAvailableCommands()).toEqual([{ name: 'help', description: 'Help' }]); + expect(permissionBridgeService.setActiveSession).toHaveBeenCalledWith(undefined); + expect(sessionModelChanges).toEqual([undefined]); + expect(availableCommandsChanges).toEqual([]); + expect(modeChanges).toEqual(['']); + expect(sessionChanges).toEqual(['']); + }); + + it('stores draft config option changes and applies them to the first created ACP session', async () => { + const { aiBackService, model, service } = createService(); + service._sessionModel = model; + service.enterDraftSession(); + + await service.setSessionConfigOption('approval', 'always'); + + expect(aiBackService.setSessionConfigOption).not.toHaveBeenCalled(); + expect(service.getDraftSessionState().configOptions[0].currentValue).toBe('always'); + + await expect(service.ensureSessionModel()).resolves.toBe(model); + + expect(aiBackService.setSessionConfigOption).toHaveBeenCalledWith('sess-1', 'approval', 'always'); + expect(service.sessionModel.configOptions[0].currentValue).toBe('always'); + }); + + it('clears the current ACP session into draft without creating another session', async () => { + const { chatManagerService, model, permissionBridgeService, service } = createService(); + service._sessionModel = model; + + await service.clearSessionModel(); + + expect(chatManagerService.clearSession).toHaveBeenCalledWith('acp:sess-1'); + expect(chatManagerService.startSession).not.toHaveBeenCalled(); + expect(permissionBridgeService.clearSessionDialogs).toHaveBeenCalledWith('sess-1'); + expect(service.sessionModel).toBeUndefined(); + }); + + it('falls back to draft when loading an ACP session fails', async () => { + const { chatManagerService, messageService, service } = createService(); + chatManagerService.loadSession.mockRejectedValueOnce(new Error('Session not found')); + + await service.activateSession('acp:missing'); + + expect(chatManagerService.startSession).not.toHaveBeenCalled(); + expect(messageService.info).toHaveBeenCalledWith( + 'This chat history is no longer available. A new chat draft is ready, and a session will be created when you send a message.', + ); + expect(service.sessionModel).toBeUndefined(); + }); + }); + + describe('formatAcpLoadSessionFallbackMessage()', () => { + it('returns a friendly fallback message for object-shaped errors', () => { + expect( + formatAcpLoadSessionFallbackMessage({ + code: -32603, + data: { + sessionId: 'a3e1d854-a698-463b-9492-10b8638f30e3', + }, + }), + ).toBe( + 'Unable to open this chat history. A new chat draft is ready, and a session will be created when you send a message.', + ); + }); + + it('returns a friendly not-found message when the session no longer exists', () => { + expect(formatAcpLoadSessionFallbackMessage(new Error('Session not found'))).toBe( + 'This chat history is no longer available. A new chat draft is ready, and a session will be created when you send a message.', + ); + }); + }); +}); diff --git a/packages/ai-native/__test__/browser/chat/acp-chat-manager.service.test.ts b/packages/ai-native/__test__/browser/chat/acp-chat-manager.service.test.ts new file mode 100644 index 0000000000..69915d40ec --- /dev/null +++ b/packages/ai-native/__test__/browser/chat/acp-chat-manager.service.test.ts @@ -0,0 +1,746 @@ +import { ChatMessageRole } from '@opensumi/ide-core-common'; + +import { ACPSessionProvider } from '../../../src/browser/chat/acp-session-provider'; +import { AcpChatManagerService } from '../../../src/browser/chat/chat-manager.service.acp'; +import { ChatModel } from '../../../src/browser/chat/chat-model'; +import { ChatFeatureRegistry } from '../../../src/browser/chat/chat.feature.registry'; + +describe('AcpChatManagerService', () => { + const createService = () => { + const service = Object.create(AcpChatManagerService.prototype) as AcpChatManagerService & { + aiNativeConfig: any; + chatFeatureRegistry: ChatFeatureRegistry; + sessionModels: Map; + mainProvider: any; + acpTitleStorage: any; + acpSessionDisplayTitleOverrides: Record; + storageInitEmitter: any; + listenSession: jest.Mock; + fromAcpJSON(data: any[]): ChatModel[]; + toSessionData(model: ChatModel): any; + }; + + Object.defineProperty(service, 'aiNativeConfig', { + value: { + capabilities: { + supportsAgentMode: true, + }, + }, + }); + Object.defineProperty(service, 'chatFeatureRegistry', { + value: new ChatFeatureRegistry(), + }); + Object.defineProperty(service, 'logger', { + value: { + log: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + }, + }); + Object.defineProperty(service, 'sessionModels', { + value: new Map(), + }); + Object.defineProperty(service, 'acpSessionDisplayTitleOverrides', { + value: {}, + writable: true, + }); + Object.defineProperty(service, 'acpTitleStorage', { + value: undefined, + writable: true, + }); + Object.defineProperty(service, 'storageInitEmitter', { + value: { + fireAndAwait: jest.fn().mockResolvedValue(undefined), + }, + }); + Object.defineProperty(service, 'listenSession', { + value: jest.fn(), + }); + + return service; + }; + + const createConstructedService = () => { + const aiNativeConfig = { + capabilities: { + supportsAgentMode: true, + }, + }; + const prototype = AcpChatManagerService.prototype as any; + const originalAiNativeConfig = Object.getOwnPropertyDescriptor(prototype, 'aiNativeConfig'); + const originalSessionProviderRegistry = Object.getOwnPropertyDescriptor(prototype, 'sessionProviderRegistry'); + + Object.defineProperty(prototype, 'aiNativeConfig', { + configurable: true, + get: () => aiNativeConfig, + }); + Object.defineProperty(prototype, 'sessionProviderRegistry', { + configurable: true, + get: () => ({ + getAllProviders: () => [], + }), + }); + + let service!: AcpChatManagerService & { + chatFeatureRegistry: ChatFeatureRegistry; + acpTitleStorage: any; + acpSessionDisplayTitleOverrides: Record; + }; + + try { + service = new AcpChatManagerService() as typeof service; + } finally { + if (originalAiNativeConfig) { + Object.defineProperty(prototype, 'aiNativeConfig', originalAiNativeConfig); + } else { + delete prototype.aiNativeConfig; + } + if (originalSessionProviderRegistry) { + Object.defineProperty(prototype, 'sessionProviderRegistry', originalSessionProviderRegistry); + } else { + delete prototype.sessionProviderRegistry; + } + } + + Object.defineProperty(service, 'aiNativeConfig', { + value: aiNativeConfig, + }); + Object.defineProperty(service, 'chatFeatureRegistry', { + value: new ChatFeatureRegistry(), + }); + Object.defineProperty(service, 'logger', { + value: { + log: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + }, + }); + Object.defineProperty(service, 'acpSessionDisplayTitleOverrides', { + value: {}, + writable: true, + }); + + const storage = { + set: jest.fn(), + }; + Object.defineProperty(service, 'acpTitleStorage', { + value: storage, + writable: true, + }); + + return { service, storage }; + }; + + const createSessionProvider = () => { + const provider = Object.create(ACPSessionProvider.prototype) as ACPSessionProvider & { + aiBackService: any; + configProvider: any; + loadedSessionMap: Map; + messageService: any; + convertAgentSessionToModel(sessionId: string, agentSession: any): any; + }; + + Object.defineProperty(provider, 'configProvider', { + value: { + resolveConfig: jest.fn().mockResolvedValue({ cwd: '/workspace' }), + }, + }); + Object.defineProperty(provider, 'messageService', { + value: { + error: jest.fn(), + }, + }); + Object.defineProperty(provider, 'loadedSessionMap', { + value: new Map(), + }); + + return provider; + }; + + it('sets creation time when creating an ACP session', async () => { + const provider = createSessionProvider(); + Object.defineProperty(provider, 'aiBackService', { + value: { + createSession: jest.fn().mockResolvedValue({ + sessionId: 's1', + }), + }, + }); + const dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(12345); + + try { + const session = await provider.createSession(); + + expect(session.createdAt).toBe(12345); + } finally { + dateNowSpy.mockRestore(); + } + }); + + it('uses the first agent message timestamp as loaded ACP session creation time', () => { + const provider = createSessionProvider(); + + const session = provider.convertAgentSessionToModel('acp:s1', { + sessionId: 's1', + messages: [ + { + role: 'user', + content: 'first prompt', + timestamp: 67890, + }, + { + role: 'assistant', + content: 'reply', + timestamp: 67891, + }, + ], + }); + + expect(session.createdAt).toBe(67890); + }); + + it('keeps the first empty ACP session list result retryable before caching confirmed empty history', async () => { + const provider = createSessionProvider(); + const listSessions = jest.fn().mockResolvedValue({ sessions: [] }); + Object.defineProperty(provider, 'aiBackService', { + value: { + listSessions, + }, + }); + + await expect(provider.loadSessions()).resolves.toEqual([]); + await expect(provider.loadSessions()).resolves.toEqual([]); + await expect(provider.loadSessions()).resolves.toEqual([]); + + expect(listSessions).toHaveBeenCalledTimes(2); + }); + + it('reuses the in-flight ACP session list request', async () => { + const provider = createSessionProvider(); + let resolveListSessions!: (value: any) => void; + const listSessions = jest.fn( + () => + new Promise((resolve) => { + resolveListSessions = resolve; + }), + ); + Object.defineProperty(provider, 'aiBackService', { + value: { + listSessions, + }, + }); + + const first = provider.loadSessions(); + const second = provider.loadSessions(); + + await Promise.resolve(); + + expect(listSessions).toHaveBeenCalledTimes(1); + + resolveListSessions({ + sessions: [ + { + sessionId: 's1', + title: 'Session 1', + }, + ], + }); + + await expect(Promise.all([first, second])).resolves.toEqual([ + [ + expect.objectContaining({ + sessionId: 'acp:s1', + title: 'Session 1', + }), + ], + [ + expect.objectContaining({ + sessionId: 'acp:s1', + title: 'Session 1', + }), + ], + ]); + }); + + it('preserves metadata title when loading a full ACP session without title', async () => { + const service = createService(); + const sessionId = 'acp:s1'; + const metadataModel = service.fromAcpJSON([ + { + sessionId, + history: { + additional: {}, + messages: [], + }, + requests: [], + title: 'commit', + }, + ])[0]; + + service.sessionModels.set(sessionId, metadataModel); + Object.defineProperty(service, 'mainProvider', { + value: { + loadSession: jest.fn().mockResolvedValue({ + sessionId, + history: { + additional: {}, + messages: [ + { + id: `${sessionId}-msg-0`, + role: ChatMessageRole.User, + content: 'first prompt', + order: 0, + }, + ], + }, + requests: [], + }), + }, + }); + + await service.loadSession(sessionId); + + const loadedModel = service.sessionModels.get(sessionId); + expect(loadedModel?.title).toBe('commit'); + expect(loadedModel?.history.getMessages()).toHaveLength(1); + }); + + it('preserves creation time when restoring and serializing ACP sessions', () => { + const service = createService(); + const [model] = service.fromAcpJSON([ + { + sessionId: 'acp:s-created', + createdAt: 12345, + history: { + additional: {}, + messages: [], + }, + requests: [], + title: 'created session', + }, + ]); + + expect(model.createdAt).toBe(12345); + expect(service.toSessionData(model).createdAt).toBe(12345); + }); + + it('keeps existing list title when a full ACP session is loaded', async () => { + const service = createService(); + const sessionId = 'acp:s1'; + const metadataModel = service.fromAcpJSON([ + { + sessionId, + history: { + additional: {}, + messages: [], + }, + requests: [], + title: 'OpenSumi exposes IDE capabilities through the opensumi-ide MCP server.', + }, + ])[0]; + service.acpTitleStorage = { + set: jest.fn(), + }; + + service.sessionModels.set(sessionId, metadataModel); + Object.defineProperty(service, 'mainProvider', { + value: { + loadSession: jest.fn().mockResolvedValue({ + sessionId, + history: { + additional: {}, + messages: [ + { + id: `${sessionId}-msg-0`, + role: ChatMessageRole.User, + content: 'OpenSumi exposes IDE capabilities through the opensumi-ide MCP server.\n\n---\n\n3', + order: 0, + }, + ], + }, + requests: [], + }), + }, + }); + + await service.loadSession(sessionId); + + expect(service.sessionModels.get(sessionId)?.title).toBe('Session s1'); + expect(service.acpTitleStorage.set).not.toHaveBeenCalled(); + }); + + it('uses local display title override before polluted agent title', () => { + const service = createService(); + service.acpSessionDisplayTitleOverrides = { + 'acp:s1': '3', + }; + + const [model] = service.fromAcpJSON([ + { + sessionId: 'acp:s1', + history: { + additional: {}, + messages: [], + }, + requests: [], + title: 'OpenSumi exposes IDE capabilities through the opensumi-ide MCP server.', + }, + ]); + + expect(model.title).toBe('3'); + }); + + it('does not load full sessions when rendering the history list', async () => { + const service = createService(); + service.mainProvider = { + loadSessions: jest.fn().mockResolvedValue([ + { + sessionId: 'acp:s1', + history: { + additional: {}, + messages: [], + }, + requests: [], + title: 'OpenSumi exposes IDE capabilities through the opensumi-ide MCP server.', + }, + ]), + loadSession: jest.fn().mockResolvedValue({ + sessionId: 'acp:s1', + history: { + additional: {}, + messages: [ + { + id: 'acp:s1-msg-0', + role: ChatMessageRole.User, + content: 'OpenSumi exposes IDE capabilities through the opensumi-ide MCP server.\n\n---\n\n3', + order: 0, + }, + ], + }, + requests: [], + }), + }; + + await service.loadSessionList(); + + expect(service.mainProvider.loadSession).not.toHaveBeenCalled(); + expect(service.sessionModels.get('acp:s1')?.title).toBe('Session s1'); + }); + + it('uses local override on history list without loading full session data', async () => { + const service = createService(); + service.acpSessionDisplayTitleOverrides = { + 'acp:s1': '3', + }; + service.mainProvider = { + loadSessions: jest.fn().mockResolvedValue([ + { + sessionId: 'acp:s1', + history: { + additional: {}, + messages: [], + }, + requests: [], + title: 'OpenSumi exposes IDE capabilities through the opensumi-ide MCP server.', + }, + ]), + loadSession: jest.fn().mockResolvedValue({ + sessionId: 'acp:s1', + history: { + additional: {}, + messages: [ + { + id: 'acp:s1-msg-0', + role: ChatMessageRole.User, + content: 'OpenSumi exposes IDE capabilities through the opensumi-ide MCP server.\n\n---\n\n3', + order: 0, + }, + ], + }, + requests: [], + }), + }; + + await service.loadSessionList(); + + expect(service.sessionModels.get('acp:s1')?.title).toBe('3'); + expect(service.mainProvider.loadSession).not.toHaveBeenCalled(); + }); + + it('extracts list title from ACP prompt separator in metadata title', async () => { + const service = createService(); + service.mainProvider = { + loadSessions: jest.fn().mockResolvedValue([ + { + sessionId: 'acp:s1', + history: { + additional: {}, + messages: [], + }, + requests: [], + title: 'OpenSumi exposes IDE capabilities through the opensumi-ide MCP server.\n\n---\n\n3', + }, + ]), + }; + + await service.loadSessionList(); + + expect(service.sessionModels.get('acp:s1')?.title).toBe('3'); + }); + + it('keeps the current empty ACP session last in manager order after loading history list', async () => { + const service = createService(); + const currentSession = new ChatModel(new ChatFeatureRegistry(), { + sessionId: 'acp:current', + title: 'New Session', + }); + service.sessionModels.set(currentSession.sessionId, currentSession); + service.mainProvider = { + loadSessions: jest.fn().mockResolvedValue([ + { + sessionId: 'acp:s1', + history: { + additional: {}, + messages: [], + }, + requests: [], + title: 'history session', + }, + ]), + }; + + await service.loadSessionList(); + + expect(service.getSessions().map((session) => session.sessionId)).toEqual(['acp:s1', 'acp:current']); + }); + + it('keeps ACP session order stable when loading a clicked history item', async () => { + const { service } = createConstructedService(); + const firstSession = new ChatModel(new ChatFeatureRegistry(), { + sessionId: 'acp:first', + title: 'First Session', + }); + const secondSession = new ChatModel(new ChatFeatureRegistry(), { + sessionId: 'acp:second', + title: 'Second Session', + }); + + (service as any).sessionModels.set(firstSession.sessionId, firstSession); + (service as any).sessionModels.set(secondSession.sessionId, secondSession); + (service as any).mainProvider = { + loadSession: jest.fn().mockResolvedValue({ + sessionId: 'acp:first', + history: { + additional: {}, + messages: [ + { + id: 'acp:first-msg-0', + role: ChatMessageRole.User, + content: 'loaded first prompt', + order: 0, + }, + ], + }, + requests: [], + }), + }; + + await service.loadSession('acp:first'); + service.getSession('acp:first'); + + expect(service.getSessions().map((session) => session.sessionId)).toEqual(['acp:first', 'acp:second']); + expect(service.getSession('acp:first')?.history.getMessages()).toHaveLength(1); + }); + + it('stores raw first user message as ACP display title when creating request', () => { + const { service, storage } = createConstructedService(); + const sessionId = 'acp:s1'; + const model = new ChatModel(new ChatFeatureRegistry(), { sessionId }); + + (service as any).sessionModels.set(sessionId, model); + + const request = service.createRequest(sessionId, '3', 'agentId', undefined, undefined); + + expect(request?.message.prompt).toBe('3'); + expect(model.title).toBe('3'); + expect(storage.set).toHaveBeenCalledWith('acpSessionDisplayTitleOverrides', { + [sessionId]: '3', + }); + }); + + it('skips global model preference validation for ACP sessions only', () => { + const { service } = createConstructedService(); + const acpModel = new ChatModel(new ChatFeatureRegistry(), { + sessionId: 'acp:s1', + modelId: 'qwen3.6-plus', + }); + const localModel = new ChatModel(new ChatFeatureRegistry(), { + sessionId: 'local:s1', + modelId: 'MiniMax-M2.7', + }); + + expect((service as any).shouldValidateModelChange('acp:s1', acpModel)).toBe(false); + expect((service as any).shouldValidateModelChange('local:s1', localModel)).toBe(true); + }); + + it('stores raw follow-up message as display title for old polluted ACP sessions', () => { + const { service, storage } = createConstructedService(); + const sessionId = 'acp:s1'; + const model = new ChatModel(new ChatFeatureRegistry(), { + sessionId, + title: 'OpenSumi exposes IDE capabilities through the opensumi-ide MCP server.', + }); + + model.history.addUserMessage({ + agentId: 'agentId', + agentCommand: '', + content: 'OpenSumi exposes IDE capabilities through the opensumi-ide MCP server.', + relationId: '', + images: [], + }); + (service as any).sessionModels.set(sessionId, model); + + service.createRequest(sessionId, '3', 'agentId', undefined, undefined); + + expect(model.title).toBe('3'); + expect(storage.set).toHaveBeenCalledWith('acpSessionDisplayTitleOverrides', { + [sessionId]: '3', + }); + }); + + it('extracts display title from ACP prompt separator when no override exists', () => { + const service = createService(); + const [model] = service.fromAcpJSON([ + { + sessionId: 'acp:s4', + history: { + additional: {}, + messages: [ + { + id: 'acp:s4-msg-0', + role: ChatMessageRole.User, + content: 'OpenSumi exposes IDE capabilities through the opensumi-ide MCP server.\n\n---\n\n3', + order: 0, + }, + ], + }, + requests: [], + }, + ]); + + expect(model.title).toBe('3'); + }); + + it('falls back to first user message for ACP sessions with messages and no title', () => { + const service = createService(); + const [model] = service.fromAcpJSON([ + { + sessionId: 'acp:s2', + history: { + additional: {}, + messages: [ + { + id: 'acp:s2-msg-0', + role: ChatMessageRole.User, + content: 'fallback title source', + order: 0, + }, + ], + }, + requests: [], + }, + ]); + + expect(model.title).toBe('fallback title source'); + }); + + it('preserves synthetic New Session title when an existing list item loads full messages', async () => { + const service = createService(); + const sessionId = 'acp:s2'; + const metadataModel = service.fromAcpJSON([ + { + sessionId, + history: { + additional: {}, + messages: [], + }, + requests: [], + }, + ])[0]; + + expect(metadataModel.title).toBe('New Session'); + + service.sessionModels.set(sessionId, metadataModel); + Object.defineProperty(service, 'mainProvider', { + value: { + loadSession: jest.fn().mockResolvedValue({ + sessionId, + history: { + additional: {}, + messages: [ + { + id: `${sessionId}-msg-0`, + role: ChatMessageRole.User, + content: 'fallback title source', + order: 0, + }, + ], + }, + requests: [], + }), + }, + }); + + await service.loadSession(sessionId); + + expect(service.sessionModels.get(sessionId)?.title).toBe('New Session'); + }); + + it('keeps New Session as the default for empty ACP sessions', () => { + const service = createService(); + const [model] = service.fromAcpJSON([ + { + sessionId: 'acp:s3', + history: { + additional: {}, + messages: [], + }, + requests: [], + }, + ]); + + expect(model.title).toBe('New Session'); + }); + + it('applies ACP session state updates and emits a change event', () => { + const { service } = createConstructedService(); + const model = new ChatModel(new ChatFeatureRegistry(), { + sessionId: 'acp:sess-1', + modelId: 'old-model', + currentModeId: 'plan', + }); + const configOptions = [{ id: 'permission', name: 'Permission', currentValue: 'default' }]; + const changes: any[] = []; + + (service as any).sessionModels.set(model.sessionId, model); + service.onDidApplySessionState((event) => changes.push(event)); + + service.applySessionStateUpdate('sess-1', { + currentModeId: 'code', + currentModelId: 'qwen3.6-plus', + configOptions, + }); + + expect(model.currentModeId).toBe('code'); + expect(model.modelId).toBe('qwen3.6-plus'); + expect(model.configOptions).toEqual(configOptions); + expect(changes).toEqual([ + expect.objectContaining({ + sessionId: 'acp:sess-1', + model, + previousModeId: 'plan', + currentModeId: 'code', + }), + ]); + }); +}); diff --git a/packages/ai-native/__test__/browser/chat/chat-agent.service.test.ts b/packages/ai-native/__test__/browser/chat/chat-agent.service.test.ts index 549992cabe..42045042ef 100644 --- a/packages/ai-native/__test__/browser/chat/chat-agent.service.test.ts +++ b/packages/ai-native/__test__/browser/chat/chat-agent.service.test.ts @@ -1,12 +1,12 @@ -import { CancellationToken, Emitter } from '@opensumi/ide-core-common'; +import { CancellationToken, Emitter, IAIReporter, ILogger } from '@opensumi/ide-core-common'; import { ChatFeatureRegistryToken, ChatServiceToken } from '@opensumi/ide-core-common/lib/types/ai-native'; import { createBrowserInjector } from '@opensumi/ide-dev-tool/src/injector-helper'; import { MockInjector } from '@opensumi/ide-dev-tool/src/mock-injector'; -import { ChatAgentService } from '../../../lib/browser/chat/chat-agent.service'; -import { IChatAgent, IChatAgentMetadata, IChatAgentRequest, IChatManagerService } from '../../../lib/common'; -import { LLMContextServiceToken } from '../../../lib/common/llm-context'; -import { ChatAgentPromptProvider } from '../../../lib/common/prompts/context-prompt-provider'; +import { ChatAgentService } from '../../../src/browser/chat/chat-agent.service'; +import { IChatAgent, IChatAgentMetadata, IChatAgentRequest, IChatManagerService } from '../../../src/common'; +import { LLMContextServiceToken } from '../../../src/common/llm-context'; +import { ChatAgentPromptProvider } from '../../../src/common/prompts/context-prompt-provider'; describe('ChatAgentService', () => { let injector: MockInjector; @@ -32,16 +32,31 @@ describe('ChatAgentService', () => { token: ChatServiceToken, useValue: {}, }, + { + token: ILogger, + useValue: { + log: jest.fn(), + error: jest.fn(), + }, + }, + { + token: IAIReporter, + useValue: { + send: jest.fn(), + }, + }, { token: LLMContextServiceToken, useValue: { onDidContextFilesChangeEvent: new Emitter().event, - serialize: () => {}, + serialize: () => ({}), }, }, { token: ChatFeatureRegistryToken, - useValue: {}, + useValue: { + registerWelcome: jest.fn(), + }, }, ]), ); @@ -66,6 +81,7 @@ describe('ChatAgentService', () => { id: 'agent1', metadata: {}, provideSlashCommands: () => Promise.resolve([]), + provideChatWelcomeMessage: () => Promise.resolve(undefined), invoke: () => {}, } as unknown as IChatAgent; chatAgentService.registerAgent(agent); @@ -86,7 +102,11 @@ describe('ChatAgentService', () => { } as unknown as IChatAgent; chatAgentService.registerAgent(agent); - const request = {} as IChatAgentRequest; + const request = { + sessionId: 'session-1', + requestId: 'request-1', + message: 'Hello', + } as IChatAgentRequest; const progress = jest.fn(); const history = []; const token = CancellationToken.None; diff --git a/packages/ai-native/__test__/browser/chat/chat-manager.service.test.ts b/packages/ai-native/__test__/browser/chat/chat-manager.service.test.ts new file mode 100644 index 0000000000..755b8a2387 --- /dev/null +++ b/packages/ai-native/__test__/browser/chat/chat-manager.service.test.ts @@ -0,0 +1,86 @@ +import { ChatManagerService } from '../../../src/browser/chat/chat-manager.service'; +import { ChatFeatureRegistry } from '../../../src/browser/chat/chat.feature.registry'; + +describe('ChatManagerService', () => { + const createService = () => { + const service = new ChatManagerService() as ChatManagerService & { + chatAgentService: any; + chatFeatureRegistry: ChatFeatureRegistry; + logger: any; + saveSessions: jest.Mock; + preferenceService: any; + }; + + Object.defineProperty(service, 'chatFeatureRegistry', { + value: new ChatFeatureRegistry(), + }); + Object.defineProperty(service, 'logger', { + value: { + log: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + }, + }); + Object.defineProperty(service, 'preferenceService', { + value: { + get: jest.fn(() => undefined), + }, + }); + Object.defineProperty(service, 'saveSessions', { + value: jest.fn(), + }); + + return service; + }; + + it('sets response error details when agent invocation throws', async () => { + const service = createService(); + const model = await service.startSession(); + const request = model.addRequest({ agentId: 'agentId', prompt: 'hello' }); + const error = new Error('request failed'); + + Object.defineProperty(service, 'chatAgentService', { + value: { + invokeAgent: jest.fn().mockRejectedValue(error), + getFollowups: jest.fn().mockResolvedValue([]), + }, + }); + + await service.sendRequest(model.sessionId, request, false); + + expect(request.response.errorDetails).toEqual({ message: error.message }); + expect(request.response.isComplete).toBe(true); + expect(service.chatAgentService.getFollowups).not.toHaveBeenCalled(); + }); + + it('completes response immediately when agent returns error details', async () => { + const service = createService(); + const model = await service.startSession(); + const request = model.addRequest({ agentId: 'agentId', prompt: 'hello' }); + + Object.defineProperty(service, 'chatAgentService', { + value: { + invokeAgent: jest.fn().mockResolvedValue({ errorDetails: { message: 'agent error' } }), + getFollowups: jest.fn().mockResolvedValue([]), + }, + }); + + await service.sendRequest(model.sessionId, request, false); + + expect(request.response.errorDetails).toEqual({ message: 'agent error' }); + expect(request.response.isComplete).toBe(true); + expect(service.chatAgentService.invokeAgent).toHaveBeenCalledWith( + request.message.agentId, + expect.objectContaining({ + sessionId: model.sessionId, + requestId: request.requestId, + message: request.message.prompt, + regenerate: false, + }), + expect.any(Function), + expect.any(Array), + expect.anything(), + ); + expect(service.chatAgentService.getFollowups).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/ai-native/__test__/browser/chat/chat-reply.test.tsx b/packages/ai-native/__test__/browser/chat/chat-reply.test.tsx new file mode 100644 index 0000000000..f498bfe06f --- /dev/null +++ b/packages/ai-native/__test__/browser/chat/chat-reply.test.tsx @@ -0,0 +1,340 @@ +import * as React from 'react'; +import { Root, createRoot } from 'react-dom/client'; +import { act } from 'react-dom/test-utils'; + +jest.mock('@opensumi/ide-components/lib/button', () => ({ + Button: ({ children, onClick }: any) => + require('react').createElement( + 'button', + { + onClick, + type: 'button', + }, + children, + ), +})); + +jest.mock('@opensumi/ide-components/lib/recycle-tree', () => ({ + BasicRecycleTree: () => null, +})); + +jest.mock('@opensumi/ide-components/lib/recycle-tree/basic/tree-node.define', () => ({ + BasicCompositeTreeNode: { + is: jest.fn(() => false), + }, + BasicTreeNode: {}, +})); + +jest.mock('@opensumi/ide-components/lib/tooltip', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => + require('react').createElement(React.Fragment, null, children), +})); + +jest.mock('@opensumi/ide-core-browser', () => { + class DisposableCollection { + private disposables: Array<{ dispose?: () => void }> = []; + + push(disposable: { dispose?: () => void }) { + this.disposables.push(disposable); + return disposable; + } + + dispose() { + this.disposables.forEach((disposable) => disposable.dispose?.()); + } + } + + return { + CommandService: Symbol('CommandService'), + DisposableCollection, + EDITOR_COMMANDS: { + OPEN_RESOURCE: { + id: 'editor.openResource', + }, + }, + IContextKeyService: Symbol('IContextKeyService'), + LabelService: Symbol('LabelService'), + useInjectable: jest.fn(), + }; +}); + +jest.mock('@opensumi/ide-core-browser/lib/components', () => ({ + Icon: ({ className, iconClass }: { className?: string; iconClass?: string }) => + require('react').createElement('span', { 'data-icon': iconClass || className }), + getIcon: (name: string) => `icon-${name}`, +})); + +jest.mock('@opensumi/ide-core-browser/lib/components/ai-native', () => ({ + Loading: () => require('react').createElement('span', null, 'loading'), +})); + +jest.mock('@opensumi/ide-core-common', () => ({ + ActionSourceEnum: { + Chat: 'Chat', + }, + ActionTypeEnum: { + Followup: 'Followup', + }, + ChatAgentViewServiceToken: Symbol('ChatAgentViewServiceToken'), + ChatRenderRegistryToken: Symbol('ChatRenderRegistryToken'), + ChatServiceToken: Symbol('ChatServiceToken'), + FileType: { + Directory: 2, + }, + IAIReporter: Symbol('IAIReporter'), + URI: class URI { + constructor(public readonly uri: string) {} + }, + localize: (key: string) => (key === 'aiNative.chat.thinking' ? 'Deep Thinking' : key), +})); + +jest.mock('@opensumi/ide-theme', () => ({ + IIconService: Symbol('IIconService'), +})); + +jest.mock('@opensumi/monaco-editor-core/esm/vs/base/common/htmlContent', () => ({ + MarkdownString: class MarkdownString { + constructor(public readonly value: string) {} + }, +})); + +jest.mock('../../../src/common', () => ({ + IChatAgentService: Symbol('IChatAgentService'), + IChatInternalService: Symbol('IChatInternalService'), +})); + +jest.mock('../../../src/browser/chat/chat-model', () => ({ + ChatRequestModel: class ChatRequestModel {}, +})); + +jest.mock('../../../src/browser/chat/chat.api.service', () => ({ + ChatService: class ChatService {}, +})); + +jest.mock('../../../src/browser/chat/chat.internal.service', () => ({ + ChatInternalService: class ChatInternalService {}, +})); + +jest.mock('../../../src/browser/chat/chat.render.registry', () => ({ + ChatRenderRegistry: class ChatRenderRegistry {}, +})); + +jest.mock('../../../src/browser/model/msg-history-manager', () => ({ + MsgHistoryManager: class MsgHistoryManager {}, +})); + +jest.mock('../../../src/browser/components/ChatMarkdown', () => ({ + ChatMarkdown: ({ markdown }: any) => + require('react').createElement('div', { 'data-testid': 'chat-markdown' }, markdown.value), +})); + +jest.mock('../../../src/browser/components/ChatThinking', () => ({ + ChatThinking: ({ children }: { children: React.ReactNode }) => + require('react').createElement('div', { 'data-testid': 'chat-thinking' }, children), + ChatThinkingResult: ({ children }: { children: React.ReactNode }) => + require('react').createElement('div', { 'data-testid': 'chat-thinking-result' }, children), +})); + +import { ChatReply } from '../../../src/browser/components/ChatReply'; + +interface ReasoningContent { + kind: 'reasoning'; + content: string; +} + +let requestIdPool = 0; + +function createRequest(responseContents: ReasoningContent[], isComplete: boolean) { + const requestId = `request-${requestIdPool++}`; + const listeners = new Set<() => void>(); + const response = { + errorDetails: undefined, + followups: undefined, + isComplete, + onDidChange: jest.fn((listener: () => void) => { + listeners.add(listener); + return { + dispose: () => listeners.delete(listener), + }; + }), + reset: jest.fn(), + responseContents, + responseParts: responseContents, + responseText: '', + }; + + return { + emitChange: () => listeners.forEach((listener) => listener()), + request: { + requestId, + response, + }, + response, + }; +} + +describe('ChatReply reasoning collapse state', () => { + let container: HTMLDivElement; + let root: Root; + let history: { updateAssistantMessage: jest.Mock }; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + history = { + updateAssistantMessage: jest.fn(), + }; + + jest.requireMock('@opensumi/ide-core-browser').useInjectable.mockImplementation((token: any) => { + const key = String(token); + + if (key.includes('IAIReporter')) { + return { + end: jest.fn(), + }; + } + + if (key.includes('IIconService')) { + return { + fromString: (icon: string) => icon, + }; + } + + if (key.includes('IChatInternalService')) { + return { + sessionModel: { + sessionId: 'session-1', + }, + }; + } + + if (key.includes('ChatServiceToken')) { + return { + sendMessage: jest.fn(), + }; + } + + if (key.includes('IChatAgentService')) { + return { + parseMessage: (message: string) => ({ message }), + }; + } + + if (key.includes('ChatRenderRegistryToken')) { + return {}; + } + + if (key.includes('IContextKeyService')) { + return { + match: jest.fn(() => true), + }; + } + + if (key.includes('ChatAgentViewServiceToken')) { + return { + getChatComponent: jest.fn(() => undefined), + getChatComponentDeferred: jest.fn(), + }; + } + + return {}; + }); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + jest.clearAllMocks(); + }); + + function renderReply(request: any, collapseReasoningByDefault = false) { + act(() => { + root.render( + , + ); + }); + } + + function getThinkingButton() { + const button = Array.from(container.querySelectorAll('button')).find((item) => + item.textContent?.includes('Deep Thinking'), + ); + expect(button).not.toBeUndefined(); + return button as HTMLButtonElement; + } + + it('collapses completed reasoning by default when requested and expands on click', () => { + const { request } = createRequest([{ kind: 'reasoning', content: 'completed thought' }], true); + + renderReply(request, true); + + expect(container.textContent).toContain('Deep Thinking'); + expect(container.textContent).not.toContain('completed thought'); + + act(() => { + getThinkingButton().click(); + }); + + expect(container.textContent).toContain('completed thought'); + }); + + it('collapses streaming reasoning by default and keeps it expanded after stream updates', async () => { + const { emitChange, request, response } = createRequest([{ kind: 'reasoning', content: 'stream thought' }], false); + + renderReply(request, true); + + expect(container.textContent).toContain('Deep Thinking'); + expect(container.textContent).not.toContain('stream thought'); + + act(() => { + getThinkingButton().click(); + }); + + expect(container.textContent).toContain('stream thought'); + + response.responseContents = [{ kind: 'reasoning', content: 'stream thought updated' }]; + response.responseParts = response.responseContents; + + await act(async () => { + emitChange(); + await Promise.resolve(); + }); + + expect(container.textContent).toContain('stream thought updated'); + + response.isComplete = true; + + await act(async () => { + emitChange(); + await Promise.resolve(); + }); + + expect(container.textContent).toContain('stream thought updated'); + + act(() => { + root.render(); + }); + + renderReply(request, true); + + expect(container.textContent).toContain('stream thought updated'); + }); + + it('keeps streaming reasoning expanded by default for normal chat replies', () => { + const { request } = createRequest([{ kind: 'reasoning', content: 'normal stream thought' }], false); + + renderReply(request); + + expect(container.textContent).toContain('Deep Thinking'); + expect(container.textContent).toContain('normal stream thought'); + }); +}); diff --git a/packages/ai-native/__test__/browser/chat/default-acp-config-provider.test.ts b/packages/ai-native/__test__/browser/chat/default-acp-config-provider.test.ts new file mode 100644 index 0000000000..acc00fdcc2 --- /dev/null +++ b/packages/ai-native/__test__/browser/chat/default-acp-config-provider.test.ts @@ -0,0 +1,74 @@ +import { AINativeSettingSectionsId } from '@opensumi/ide-core-common'; + +import { DefaultACPConfigProvider } from '../../../src/browser/chat/default-acp-config-provider'; +import { pickWorkspaceDir } from '../../../src/browser/chat/pick-workspace-dir'; + +jest.mock('../../../src/browser/chat/pick-workspace-dir', () => ({ + pickWorkspaceDir: jest.fn().mockResolvedValue('/workspace'), +})); + +describe('DefaultACPConfigProvider', () => { + function createProvider(webMcpEnabled: boolean) { + const provider = Object.create(DefaultACPConfigProvider.prototype) as DefaultACPConfigProvider & { + preferenceService: { + get: jest.Mock; + }; + workspaceService: { + whenReady: Promise; + }; + quickPick: Record; + messageService: Record; + mcpConfigService: { + getACPServers: jest.Mock; + isBuiltinMCPEnabled: jest.Mock; + }; + }; + + Object.defineProperties(provider, { + preferenceService: { + value: { + get: jest.fn((id: string, fallback: unknown) => { + if (id === AINativeSettingSectionsId.DefaultAgentType) { + return 'claude-agent-acp'; + } + if (id === AINativeSettingSectionsId.AgentConfigs) { + return {}; + } + if (id === 'ai-native.acp.nodePath') { + return ''; + } + if (id === 'ai-native.acp.agents') { + return {}; + } + if (id === AINativeSettingSectionsId.AcpThreadPoolSize) { + return fallback; + } + return fallback; + }), + }, + }, + workspaceService: { value: { whenReady: Promise.resolve() } }, + quickPick: { value: {} }, + messageService: { value: {} }, + mcpConfigService: { + value: { + getACPServers: jest.fn().mockResolvedValue([]), + isBuiltinMCPEnabled: jest.fn().mockResolvedValue(webMcpEnabled), + }, + }, + }); + + return provider; + } + + it('uses unified built-in MCP state for ACP WebMCP exposure', async () => { + const provider = createProvider(false); + + const config = await provider.resolveConfig(); + + expect((provider as any).mcpConfigService.isBuiltinMCPEnabled).toHaveBeenCalled(); + expect((provider as any).mcpConfigService.getACPServers).toHaveBeenCalled(); + expect(config.webMcp).toEqual({ enabled: false }); + expect(pickWorkspaceDir).toHaveBeenCalled(); + }); +}); diff --git a/packages/ai-native/__test__/browser/mcp-config.service.test.ts b/packages/ai-native/__test__/browser/mcp-config.service.test.ts new file mode 100644 index 0000000000..9add0f5d95 --- /dev/null +++ b/packages/ai-native/__test__/browser/mcp-config.service.test.ts @@ -0,0 +1,189 @@ +import { AINativeSettingSectionsId } from '@opensumi/ide-core-common'; + +import { WebMcpGroupRegistry } from '../../src/browser/acp/webmcp-group-registry'; +import { MCPConfigService } from '../../src/browser/mcp/config/mcp-config.service'; +import { BUILTIN_MCP_SERVER_NAME } from '../../src/common'; +import { MCPServersDisabledKey } from '../../src/common/mcp-server-manager'; + +import type { WebMcpProfile } from '@opensumi/ide-core-common/lib/types/ai-native/acp-types'; + +function createStorage(initial: Record = {}) { + const data = { ...initial }; + return { + data, + get: jest.fn((key: string, defaultValue: unknown) => (key in data ? data[key] : defaultValue)), + set: jest.fn((key: string, value: unknown) => { + data[key] = value; + }), + }; +} + +function createService( + options: { + disabledServers?: string[]; + webMcpEnabled?: boolean; + webMcpProfile?: WebMcpProfile; + webMcpGroupRegistry?: WebMcpGroupRegistry; + } = {}, +) { + const preferences: Record = { + [AINativeSettingSectionsId.WebMcpEnabled]: options.webMcpEnabled ?? true, + [AINativeSettingSectionsId.WebMcpProfile]: options.webMcpProfile ?? 'default', + }; + const chatStorage = createStorage({ + [MCPServersDisabledKey]: options.disabledServers ?? [], + }); + const preferenceService = { + get: jest.fn((id: string, fallback: unknown) => (id in preferences ? preferences[id] : fallback)), + set: jest.fn(async (id: string, value: unknown) => { + preferences[id] = value; + }), + }; + const service = Object.create(MCPConfigService.prototype) as MCPConfigService & { + whenReadyDeferred: { promise: Promise }; + mcpServerProxyService: { + $startServer: jest.Mock; + $stopServer: jest.Mock; + }; + preferenceService: typeof preferenceService; + chatStorage: ReturnType; + logger: { error: jest.Mock }; + messageService: { error: jest.Mock }; + mcpServersChangeEventEmitter: { fire: jest.Mock }; + webMcpGroupRegistry: WebMcpGroupRegistry; + }; + + Object.defineProperties(service, { + whenReadyDeferred: { value: { promise: Promise.resolve() } }, + mcpServerProxyService: { + value: { + $startServer: jest.fn().mockResolvedValue(undefined), + $stopServer: jest.fn().mockResolvedValue(undefined), + }, + }, + preferenceService: { value: preferenceService }, + chatStorage: { value: chatStorage }, + logger: { value: { error: jest.fn() } }, + messageService: { value: { error: jest.fn() } }, + mcpServersChangeEventEmitter: { value: { fire: jest.fn() } }, + webMcpGroupRegistry: { + value: + options.webMcpGroupRegistry ?? + ({ + getGroupDefinitions: jest.fn(() => []), + } as unknown as WebMcpGroupRegistry), + }, + }); + + return { + service, + preferences, + preferenceService, + chatStorage, + proxy: (service as any).mcpServerProxyService, + }; +} + +describe('MCPConfigService unified built-in MCP management', () => { + it('disables traditional Builtin MCP and WebMCP together', async () => { + const { service, chatStorage, preferenceService, proxy } = createService(); + + await service.setBuiltinMCPEnabled(false); + + expect(proxy.$stopServer).toHaveBeenCalledWith(BUILTIN_MCP_SERVER_NAME); + expect(chatStorage.data[MCPServersDisabledKey]).toContain(BUILTIN_MCP_SERVER_NAME); + expect(preferenceService.set).toHaveBeenCalledWith(AINativeSettingSectionsId.WebMcpEnabled, false); + }); + + it('enables traditional Builtin MCP and WebMCP together', async () => { + const { service, chatStorage, preferenceService, proxy } = createService({ + disabledServers: [BUILTIN_MCP_SERVER_NAME], + webMcpEnabled: false, + }); + + await service.setBuiltinMCPEnabled(true); + + expect(proxy.$startServer).toHaveBeenCalledWith(BUILTIN_MCP_SERVER_NAME); + expect(chatStorage.data[MCPServersDisabledKey]).not.toContain(BUILTIN_MCP_SERVER_NAME); + expect(preferenceService.set).toHaveBeenCalledWith(AINativeSettingSectionsId.WebMcpEnabled, true); + }); + + it('treats Builtin as disabled when either stored server state or WebMCP preference is disabled', async () => { + await expect( + createService({ + disabledServers: [BUILTIN_MCP_SERVER_NAME], + webMcpEnabled: true, + }).service.isBuiltinMCPEnabled(), + ).resolves.toBe(false); + + await expect( + createService({ + disabledServers: [], + webMcpEnabled: false, + }).service.isBuiltinMCPEnabled(), + ).resolves.toBe(false); + }); + + it('updates WebMCP profile and reflects the registry profile-sized groups', async () => { + const preferences: Record = { + [AINativeSettingSectionsId.WebMcpProfile]: 'default', + }; + const previousUrl = window.location.href; + window.history.pushState({}, '', '/'); + const registry = new WebMcpGroupRegistry(); + Object.defineProperty(registry, 'preferenceService', { + value: { + get: jest.fn((id: string, fallback: unknown) => (id in preferences ? preferences[id] : fallback)), + }, + writable: true, + }); + registry.registerGroup({ + name: 'terminal', + description: 'Terminal capabilities', + defaultLoaded: true, + tools: [ + { + name: 'terminal_read_output', + description: 'Read terminal output', + riskLevel: 'read', + inputSchema: {}, + execute: jest.fn(), + }, + { + name: 'terminal_run_command', + description: 'Run terminal command', + riskLevel: 'shell', + profiles: ['interactive', 'full'], + inputSchema: {}, + execute: jest.fn(), + }, + ], + }); + + try { + const { service, preferenceService } = createService({ + webMcpProfile: 'default', + webMcpGroupRegistry: registry, + }); + preferenceService.set.mockImplementation(async (id: string, value: unknown) => { + preferences[id] = value; + }); + + expect(service.getWebMcpGroups()).toEqual([ + { + name: 'terminal', + description: 'Terminal capabilities', + defaultLoaded: true, + toolCount: 1, + }, + ]); + + await service.setWebMcpProfile('interactive'); + + expect(preferenceService.set).toHaveBeenCalledWith(AINativeSettingSectionsId.WebMcpProfile, 'interactive'); + expect(service.getWebMcpGroups()[0].toolCount).toBe(2); + } finally { + window.history.pushState({}, '', previousUrl); + } + }); +}); diff --git a/packages/ai-native/__test__/browser/panel-layout.service.test.ts b/packages/ai-native/__test__/browser/panel-layout.service.test.ts new file mode 100644 index 0000000000..a02aaa82b8 --- /dev/null +++ b/packages/ai-native/__test__/browser/panel-layout.service.test.ts @@ -0,0 +1,247 @@ +import { DesignLayoutConfig } from '@opensumi/ide-core-browser/lib/layout/constants'; +import { AINativeSettingSectionsId, PreferenceScope } from '@opensumi/ide-core-common'; + +import { + AIPanelLayoutService, + AI_AGENTIC_CHAT_DEFAULT_SIZE, + AI_AGENTIC_LAYOUT_STORAGE_KEY, + AI_CLASSIC_CHAT_DEFAULT_SIZE, + AI_PANEL_LAYOUT_CONTEXT, + getPanelLayoutStorageKey, + normalizePanelLayoutMode, +} from '../../src/browser/layout/panel-layout.service'; +import { AI_CHAT_VIEW_ID } from '../../src/common'; + +describe('AIPanelLayoutService', () => { + const createService = ({ + designLayout = 'classic', + inspectValue: initialInspectValue = {}, + setError, + aiChatPrevSize, + aiChatVisible = false, + }: { + designLayout?: 'classic' | 'agentic'; + inspectValue?: { globalValue?: unknown; workspaceValue?: unknown; workspaceFolderValue?: unknown }; + setError?: Error; + aiChatPrevSize?: number; + aiChatVisible?: boolean; + } = {}) => { + let inspectValue = initialInspectValue; + const contextKey = { + set: jest.fn(), + }; + let preferenceChangeCallback: (() => void) | undefined; + const preferenceService = { + ready: { + then: jest.fn((callback: () => void) => { + callback(); + return Promise.resolve(); + }), + }, + inspect: jest.fn(() => inspectValue), + set: jest.fn((_preferenceName, value) => { + if (setError) { + return Promise.reject(setError); + } + inspectValue = { + ...inspectValue, + globalValue: value, + }; + return Promise.resolve(); + }), + onSpecificPreferenceChange: jest.fn((_preferenceName, callback: () => void) => { + preferenceChangeCallback = callback; + return { dispose: jest.fn() }; + }), + }; + const layoutService = { + setLayoutStateKey: jest.fn(), + toggleSlot: jest.fn(), + isVisible: jest.fn(() => aiChatVisible), + getTabbarService: jest.fn(() => ({ + prevSize: aiChatPrevSize, + })), + }; + const service = new AIPanelLayoutService(); + + Object.defineProperty(service, 'preferenceService', { + value: preferenceService, + }); + Object.defineProperty(service, 'designLayoutConfig', { + value: { panelLayout: designLayout }, + }); + Object.defineProperty(service, 'contextKeyService', { + value: { + createKey: jest.fn(() => contextKey), + }, + }); + Object.defineProperty(service, 'layoutService', { + value: layoutService, + }); + + return { + contextKey, + layoutService, + preferenceService, + service, + triggerPreferenceChange: () => preferenceChangeCallback?.(), + }; + }; + + it('should preserve valid values and fall back to the default for unknown values', () => { + expect(normalizePanelLayoutMode('agentic')).toBe('agentic'); + expect(normalizePanelLayoutMode('classic')).toBe('classic'); + expect(normalizePanelLayoutMode('unknown')).toBe('classic'); + expect(normalizePanelLayoutMode(undefined)).toBe('classic'); + }); + + it('should map panel layout modes to isolated layout storage keys', () => { + expect(getPanelLayoutStorageKey('classic')).toBe('layout'); + expect(getPanelLayoutStorageKey('agentic')).toBe(AI_AGENTIC_LAYOUT_STORAGE_KEY); + }); + + it('should default to classic without preference or app config', () => { + const { service } = createService(); + + expect(service.getLayoutMode()).toBe('classic'); + }); + + it('should fall back to classic for an invalid user preference', () => { + const { service } = createService({ + designLayout: 'agentic', + inspectValue: { globalValue: 'unknown' }, + }); + + expect(service.getLayoutMode()).toBe('classic'); + }); + + it('should default design layout config to classic without an override', () => { + const designLayoutConfig = new DesignLayoutConfig(); + + expect(designLayoutConfig.panelLayout).toBe('classic'); + + designLayoutConfig.setLayout({ panelLayout: 'agentic' }); + + expect(designLayoutConfig.panelLayout).toBe('agentic'); + }); + + it('should use app config when no user preference is set', () => { + const { service } = createService({ designLayout: 'agentic' }); + + expect(service.getLayoutMode()).toBe('agentic'); + }); + + it('should let user preference override app config', () => { + const { service } = createService({ + designLayout: 'classic', + inspectValue: { globalValue: 'agentic' }, + }); + + expect(service.getLayoutMode()).toBe('agentic'); + }); + + it('should initialize layout state and context key from the current mode', () => { + const { layoutService, service } = createService({ inspectValue: { globalValue: 'agentic' } }); + + service.initialize(); + + expect((service as any).contextKeyService.createKey).toHaveBeenCalledWith(AI_PANEL_LAYOUT_CONTEXT, 'agentic'); + expect(layoutService.setLayoutStateKey).toHaveBeenCalledWith(AI_AGENTIC_LAYOUT_STORAGE_KEY, { + saveCurrent: false, + }); + }); + + it('should persist layout changes and reveal AI chat without reloading the shell', async () => { + const { contextKey, layoutService, preferenceService, service } = createService({ designLayout: 'classic' }); + + service.initialize(); + await service.setLayoutMode('agentic'); + + expect((service as any).contextKeyService.createKey).toHaveBeenCalledWith(AI_PANEL_LAYOUT_CONTEXT, 'classic'); + expect(contextKey.set).toHaveBeenCalledWith('agentic'); + expect(layoutService.setLayoutStateKey).toHaveBeenCalledWith('layout', { saveCurrent: false }); + expect(layoutService.setLayoutStateKey).toHaveBeenCalledWith(AI_AGENTIC_LAYOUT_STORAGE_KEY, { saveCurrent: true }); + expect(layoutService.toggleSlot).toHaveBeenCalledWith(AI_CHAT_VIEW_ID, true, AI_AGENTIC_CHAT_DEFAULT_SIZE); + expect(preferenceService.set).toHaveBeenCalledWith( + AINativeSettingSectionsId.PanelLayout, + 'agentic', + PreferenceScope.User, + ); + }); + + it('should not update layout when persisting layout fails', async () => { + const { contextKey, layoutService, service } = createService({ setError: new Error('write failed') }); + + service.initialize(); + + await expect(service.setLayoutMode('classic')).rejects.toThrow('write failed'); + expect(contextKey.set).not.toHaveBeenCalledWith('classic'); + expect(layoutService.toggleSlot).not.toHaveBeenCalled(); + }); + + it('should open classic AI chat with the classic fallback size', () => { + const { layoutService, service } = createService(); + + service.showAIChatView('classic'); + + expect(layoutService.toggleSlot).toHaveBeenCalledWith(AI_CHAT_VIEW_ID, true, AI_CLASSIC_CHAT_DEFAULT_SIZE); + }); + + it('should cap stale classic AI chat sizes when opening from the avatar', () => { + const { layoutService, service } = createService({ aiChatPrevSize: 1794 }); + + service.toggleAIChatView('classic'); + + expect(layoutService.toggleSlot).toHaveBeenCalledWith(AI_CHAT_VIEW_ID, undefined, 1080); + }); + + it('should not force a classic AI chat size when closing from the avatar', () => { + const { layoutService, service } = createService({ aiChatVisible: true, aiChatPrevSize: 600 }); + + service.toggleAIChatView('classic'); + + expect(layoutService.toggleSlot).toHaveBeenCalledWith(AI_CHAT_VIEW_ID, undefined, undefined); + }); + + it('should use the classic AI chat fallback size when opening from the avatar', () => { + const { layoutService, service } = createService(); + + service.toggleAIChatView('classic'); + + expect(layoutService.toggleSlot).toHaveBeenCalledWith(AI_CHAT_VIEW_ID, undefined, AI_CLASSIC_CHAT_DEFAULT_SIZE); + }); + + it('should use the agentic AI chat default size in agentic mode', () => { + const { layoutService, service } = createService(); + + service.showAIChatView('agentic'); + + expect(layoutService.toggleSlot).toHaveBeenCalledWith(AI_CHAT_VIEW_ID, true, AI_AGENTIC_CHAT_DEFAULT_SIZE); + }); + + it('should toggle both layout modes', async () => { + const { layoutService, preferenceService, service } = createService({ inspectValue: { globalValue: 'agentic' } }); + + await service.toggleLayoutMode(); + + expect(preferenceService.set).toHaveBeenCalledWith( + AINativeSettingSectionsId.PanelLayout, + 'classic', + PreferenceScope.User, + ); + expect(layoutService.toggleSlot).toHaveBeenCalledWith(AI_CHAT_VIEW_ID, true, AI_CLASSIC_CHAT_DEFAULT_SIZE); + }); + + it('should apply external preference changes to the active layout shell', () => { + const { contextKey, layoutService, service, triggerPreferenceChange } = createService({ + inspectValue: { globalValue: 'classic' }, + }); + + service.initialize(); + triggerPreferenceChange(); + + expect(contextKey.set).toHaveBeenCalledWith('classic'); + expect(layoutService.setLayoutStateKey).toHaveBeenCalledWith('layout', { saveCurrent: true }); + expect(layoutService.toggleSlot).toHaveBeenCalledWith(AI_CHAT_VIEW_ID, true, AI_CLASSIC_CHAT_DEFAULT_SIZE); + }); +}); 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..06135781b8 --- /dev/null +++ b/packages/ai-native/__test__/browser/permission-dialog-ui.test.tsx @@ -0,0 +1,445 @@ +/** + * 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(); + expect(container.querySelector('[data-testid="acp-permission-dialog-close"]')?.getAttribute('aria-label')).toBe( + 'Close permission dialog', + ); + }); + + 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(); + expect( + container.querySelector('[data-testid="acp-permission-dialog-option-2"]')?.getAttribute('data-option-kind'), + ).toBe('reject'); + expect(container.querySelector('[data-testid="acp-permission-dialog-option-2"]')?.getAttribute('aria-label')).toBe( + 'Permission option Reject', + ); + }); + + 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-acp-chat-group.test.ts b/packages/ai-native/__test__/browser/webmcp-acp-chat-group.test.ts new file mode 100644 index 0000000000..0083f0d031 --- /dev/null +++ b/packages/ai-native/__test__/browser/webmcp-acp-chat-group.test.ts @@ -0,0 +1,405 @@ +import { ChatServiceToken } from '@opensumi/ide-core-common'; +import { ChatMessageRole } from '@opensumi/ide-core-common/lib/types/ai-native'; + +import { AcpChatRelayStore } from '../../src/browser/acp/acp-chat-relay-store'; +import { AcpChatRelaySummaryProvider } from '../../src/browser/acp/acp-chat-relay-summary-provider'; +import { AcpPermissionBridgeService } from '../../src/browser/acp/permission-bridge.service'; +import { createAcpChatGroup } from '../../src/browser/acp/webmcp-groups/acp-chat.webmcp-group'; +import { IChatInternalService } from '../../src/common'; + +describe('WebMCP Group - ACP Chat', () => { + const mockSession = { + sessionId: 'acp:sess-1', + title: 'Current Session', + modelId: 'claude', + threadStatus: 'working', + requests: [{ requestId: 'req-1' }], + slicedMessageCount: 0, + history: { + getMessages: jest.fn().mockReturnValue([{ id: 'msg-1' }, { id: 'msg-2' }]), + getMemorySummaries: jest.fn().mockReturnValue([]), + }, + }; + + const targetSession = { + sessionId: 'acp:sess-2', + title: 'Target Session', + modelId: 'claude', + threadStatus: 'awaiting_prompt', + requests: [], + slicedMessageCount: 0, + history: { + getMessages: jest.fn().mockReturnValue([]), + getMemorySummaries: jest.fn().mockReturnValue([]), + }, + }; + + const mockChatInternalService = { + sessionModel: mockSession, + getSessions: jest.fn().mockReturnValue([mockSession, targetSession]), + getAvailableCommands: jest.fn().mockReturnValue([{ name: '/explain', description: 'Explain code' }]), + setSessionMode: jest.fn().mockResolvedValue(undefined), + activateSession: jest.fn().mockResolvedValue(undefined), + getSessionsByAcp: jest.fn().mockResolvedValue([mockSession, targetSession]), + loadSessionModel: jest + .fn() + .mockImplementation(async (sessionId: string) => (sessionId === 'acp:sess-2' ? targetSession : mockSession)), + }; + + const mockPermissionBridge = { + getActiveDialogCount: jest.fn().mockReturnValue(1), + getActiveSession: jest.fn().mockReturnValue('sess-1'), + getPendingCountExcludingActive: jest.fn().mockReturnValue(2), + hasPendingForSession: jest.fn().mockReturnValue(true), + showPermissionDialog: jest.fn().mockResolvedValue({ type: 'allow', optionId: 'allow_once', always: false }), + }; + + const mockChatService = { + showChatView: jest.fn(), + sendMessage: jest.fn(), + }; + + const mockRelaySummaryProvider = { + prepareSessionDigest: jest.fn().mockResolvedValue({ + digestSource: 'background_summary', + digest: 'full digest content that should stay in the relay store', + digestChars: 54, + sourceChars: 1200, + sourceTruncated: false, + }), + }; + + const mockRelayStore = { + put: jest.fn().mockImplementation((record) => ({ + ...record, + digestId: 'digest-1', + createdAt: 1000, + expiresAt: 1000 + 10 * 60 * 1000, + })), + get: jest.fn().mockReturnValue({ + digestId: 'digest-1', + sourceSessionId: 'acp:sess-1', + sourceTitle: 'Current Session', + digestSource: 'background_summary', + digest: 'full digest content for target session', + digestChars: 38, + sourceChars: 1200, + sourceTruncated: false, + createdAt: 1000, + expiresAt: 1000 + 10 * 60 * 1000, + }), + delete: jest.fn(), + }; + + function createMockContainer() { + return { + get: jest.fn().mockImplementation((token) => { + if (token === IChatInternalService) { + return mockChatInternalService; + } + if (token === AcpPermissionBridgeService) { + return mockPermissionBridge; + } + if (token === ChatServiceToken) { + return mockChatService; + } + if (token === AcpChatRelaySummaryProvider) { + return mockRelaySummaryProvider; + } + if (token === AcpChatRelayStore) { + return mockRelayStore; + } + throw new Error('DI token not mocked'); + }), + } as any; + } + + beforeEach(() => { + jest.clearAllMocks(); + mockChatInternalService.getSessions.mockReturnValue([mockSession, targetSession]); + mockChatInternalService.getSessionsByAcp.mockResolvedValue([mockSession, targetSession]); + mockSession.history.getMessages.mockReturnValue([{ id: 'msg-1' }, { id: 'msg-2' }]); + targetSession.history.getMessages.mockReturnValue([]); + mockPermissionBridge.showPermissionDialog.mockResolvedValue({ + type: 'allow', + optionId: 'allow_once', + always: false, + }); + }); + + it('registers only safe ACP chat tools by default', () => { + const group = createAcpChatGroup(createMockContainer()); + expect(group.name).toBe('acp_chat'); + expect(group.defaultLoaded).toBe(true); + + const defaultToolNames = group.tools + .filter((tool) => !tool.profiles?.length && tool.riskLevel !== 'write') + .map((tool) => tool.name); + + expect(defaultToolNames).toEqual([ + 'acp_chat_get_session_state', + 'acp_chat_get_permission_state', + 'acp_chat_show_chat_view', + ]); + expect(group.tools.map((tool) => tool.name)).not.toContain('acp_chat_sendMessage'); + expect(group.tools.map((tool) => tool.name)).not.toContain('acp_chat_handlePermissionDialog'); + }); + + it('returns active session metadata without prompt or response content', async () => { + const group = createAcpChatGroup(createMockContainer()); + const tool = group.tools.find((item) => item.name === 'acp_chat_get_session_state')!; + + const result = await tool.execute({}); + + expect(result).toMatchObject({ + success: true, + result: { + active: true, + session: { + sessionId: 'acp:sess-1', + rawSessionId: 'sess-1', + threadStatus: 'working', + requestCount: 1, + historyMessageCount: 2, + hasPendingPermission: true, + }, + }, + }); + expect(JSON.stringify(result)).not.toContain('prompt'); + expect(JSON.stringify(result)).not.toContain('responseText'); + }); + + it('loads ACP sessions before returning session list metadata', async () => { + mockChatInternalService.getSessions.mockReturnValueOnce([]); + mockChatInternalService.getSessionsByAcp.mockResolvedValueOnce([targetSession]); + const group = createAcpChatGroup(createMockContainer()); + const tool = group.tools.find((item) => item.name === 'acp_chat_list_sessions')!; + + const result = await tool.execute({}); + + expect(mockChatInternalService.getSessionsByAcp).toHaveBeenCalledTimes(1); + expect(result).toMatchObject({ + success: true, + result: { + total: 1, + sessions: [{ sessionId: 'acp:sess-2', rawSessionId: 'sess-2' }], + }, + }); + }); + + it('returns session list metadata newest first without prompt or response content', async () => { + const oldSession = { + sessionId: 'acp:old', + title: 'Old Session', + modelId: 'claude', + threadStatus: 'idle', + createdAt: 1000, + requests: [], + slicedMessageCount: 0, + history: { + getMessages: jest.fn().mockReturnValue([ + { role: ChatMessageRole.User, content: 'old prompt content', timestamp: 1000 }, + { role: ChatMessageRole.Assistant, content: 'old response content' }, + ]), + getMemorySummaries: jest.fn().mockReturnValue([]), + }, + }; + const newestByFirstMessage = { + sessionId: 'acp:newest-by-message', + title: 'Newest By Message', + modelId: 'claude', + threadStatus: 'working', + requests: [], + slicedMessageCount: 0, + history: { + getMessages: jest.fn().mockReturnValue([ + { role: ChatMessageRole.User, content: 'new prompt content', timestamp: 3000 }, + { role: ChatMessageRole.Assistant, content: 'new response content' }, + ]), + getMemorySummaries: jest.fn().mockReturnValue([]), + }, + }; + const middleSession = { + sessionId: 'acp:middle', + title: 'Middle Session', + modelId: 'claude', + threadStatus: 'awaiting_prompt', + createdAt: 2000, + requests: [], + slicedMessageCount: 0, + history: { + getMessages: jest + .fn() + .mockReturnValue([{ role: ChatMessageRole.User, content: 'middle prompt content', timestamp: 2000 }]), + getMemorySummaries: jest.fn().mockReturnValue([]), + }, + }; + const firstUntimestampedSession = { + sessionId: 'acp:first-untimestamped', + title: 'First Untimestamped Session', + modelId: 'claude', + threadStatus: 'idle', + requests: [], + slicedMessageCount: 0, + history: { + getMessages: jest.fn().mockReturnValue([{ role: ChatMessageRole.User, content: 'untimestamped prompt' }]), + getMemorySummaries: jest.fn().mockReturnValue([]), + }, + }; + const secondUntimestampedSession = { + sessionId: 'acp:second-untimestamped', + title: 'Second Untimestamped Session', + modelId: 'claude', + threadStatus: 'idle', + requests: [], + slicedMessageCount: 0, + history: { + getMessages: jest.fn().mockReturnValue([]), + getMemorySummaries: jest.fn().mockReturnValue([]), + }, + }; + mockChatInternalService.getSessionsByAcp.mockResolvedValueOnce([ + oldSession, + newestByFirstMessage, + firstUntimestampedSession, + middleSession, + secondUntimestampedSession, + ]); + const group = createAcpChatGroup(createMockContainer()); + const tool = group.tools.find((item) => item.name === 'acp_chat_list_sessions')!; + + const result = await tool.execute({}); + + expect(result).toMatchObject({ + success: true, + result: { + total: 5, + sessions: [ + { sessionId: 'acp:newest-by-message', createdAt: 3000 }, + { sessionId: 'acp:middle', createdAt: 2000 }, + { sessionId: 'acp:old', createdAt: 1000 }, + { sessionId: 'acp:second-untimestamped', createdAt: 0 }, + { sessionId: 'acp:first-untimestamped', createdAt: 0 }, + ], + }, + }); + expect(JSON.stringify(result)).not.toContain('prompt content'); + expect(JSON.stringify(result)).not.toContain('response content'); + }); + + it('returns permission counts without handling the permission decision', async () => { + const group = createAcpChatGroup(createMockContainer()); + const tool = group.tools.find((item) => item.name === 'acp_chat_get_permission_state')!; + + const result = await tool.execute({}); + + expect(result).toMatchObject({ + success: true, + result: { + activeDialogCount: 1, + activeSessionId: 'sess-1', + pendingCountExcludingActive: 2, + }, + }); + }); + + it('prepares a relay digest without returning the full digest', async () => { + const group = createAcpChatGroup(createMockContainer()); + const tool = group.tools.find((item) => item.name === 'acp_chat_prepare_session_digest')!; + + const result = await tool.execute({ sourceSessionId: 'sess-1' }); + + expect(mockRelaySummaryProvider.prepareSessionDigest).toHaveBeenCalledWith(mockSession, { + maxSourceChars: undefined, + maxDigestChars: undefined, + }); + expect(mockRelayStore.put).toHaveBeenCalledWith( + expect.objectContaining({ + sourceSessionId: 'acp:sess-1', + digest: 'full digest content that should stay in the relay store', + }), + ); + expect(result).toMatchObject({ + success: true, + result: { + digestId: 'digest-1', + sourceSessionId: 'acp:sess-1', + preview: 'full digest content that should stay in the relay store', + }, + }); + expect(JSON.stringify(result)).not.toContain('"digest":"full digest'); + }); + + it('posts a prepared relay after permission and restores the original session', async () => { + const group = createAcpChatGroup(createMockContainer()); + const tool = group.tools.find((item) => item.name === 'acp_chat_post_prepared_relay')!; + + const result = await tool.execute({ digestId: 'digest-1', targetSessionId: 'sess-2' }); + + expect(mockPermissionBridge.showPermissionDialog).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Forward ACP chat digest', + options: [ + { optionId: 'allow_once', name: 'Allow Once', kind: 'allow_once' }, + { optionId: 'reject', name: 'Reject', kind: 'reject_once' }, + ], + }), + ); + expect(mockChatInternalService.activateSession).toHaveBeenNthCalledWith(1, 'acp:sess-2'); + expect(mockChatService.sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + immediate: true, + message: expect.stringContaining('[Forwarded from ACP session: Current Session]'), + }), + ); + expect(mockChatInternalService.activateSession).toHaveBeenNthCalledWith(2, 'acp:sess-1'); + expect(mockRelayStore.delete).toHaveBeenCalledWith('digest-1'); + expect(result).toMatchObject({ + success: true, + result: { + posted: true, + targetSessionId: 'acp:sess-2', + switchedSession: true, + }, + }); + }); + + it('does not post a relay when permission is rejected', async () => { + mockPermissionBridge.showPermissionDialog.mockResolvedValueOnce({ + type: 'reject', + optionId: 'reject', + always: false, + }); + const group = createAcpChatGroup(createMockContainer()); + const tool = group.tools.find((item) => item.name === 'acp_chat_post_prepared_relay')!; + + const result = await tool.execute({ digestId: 'digest-1', targetSessionId: 'sess-2' }); + + expect(result).toMatchObject({ success: false, error: 'PERMISSION_DENIED' }); + expect(mockChatService.sendMessage).not.toHaveBeenCalled(); + }); + + it('reads bounded session message previews only in the full-profile tool', async () => { + mockSession.history.getMessages.mockReturnValue([ + { id: 'm1', order: 1, role: ChatMessageRole.User, content: 'hello' }, + { id: 'm2', order: 2, role: ChatMessageRole.Assistant, content: 'world' }, + { id: 'm3', order: 3, role: ChatMessageRole.Function, content: 'tool result' }, + ]); + const group = createAcpChatGroup(createMockContainer()); + const tool = group.tools.find((item) => item.name === 'acp_chat_read_session_messages')!; + + const result = await tool.execute({ sessionId: 'sess-1', maxMessages: 10, maxChars: 100 }); + + expect(result).toMatchObject({ + success: true, + result: { + sessionId: 'acp:sess-1', + messages: [ + { role: 'user', contentPreview: 'hello' }, + { role: 'assistant', contentPreview: 'world' }, + ], + }, + }); + expect(JSON.stringify(result)).not.toContain('tool result'); + }); +}); diff --git a/packages/ai-native/__test__/browser/webmcp-diagnostics-group.test.ts b/packages/ai-native/__test__/browser/webmcp-diagnostics-group.test.ts new file mode 100644 index 0000000000..70f0b5ba86 --- /dev/null +++ b/packages/ai-native/__test__/browser/webmcp-diagnostics-group.test.ts @@ -0,0 +1,75 @@ +import { IMarkerService } from '@opensumi/ide-markers'; + +import { createDiagnosticsGroup } from '../../src/browser/acp/webmcp-groups/diagnostics.webmcp-group'; + +describe('WebMCP diagnostics group', () => { + function createContainer(markerService: any) { + return { + get: (token: any) => { + if (token === IMarkerService) { + return markerService; + } + throw new Error('service not available'); + }, + } as any; + } + + function createMarkerService() { + const circularStats: any = { + errors: 1, + warnings: 2, + infos: 3, + unknowns: 4, + _manager: {}, + }; + circularStats._manager.stats = circularStats; + + return { + getManager: () => ({ + getMarkers: () => [], + getStats: () => circularStats, + }), + }; + } + + it('returns plain bounded stats for diagnostics_get_stats', async () => { + const group = createDiagnosticsGroup(createContainer(createMarkerService())); + const tool = group.tools.find((item) => item.name === 'diagnostics_get_stats')!; + + const result = await tool.execute({}); + + expect(result).toEqual({ + success: true, + result: { + errors: 1, + warnings: 2, + infos: 3, + unknowns: 4, + }, + }); + expect(() => JSON.stringify(result)).not.toThrow(); + }); + + it('returns plain bounded stats from diagnostics_list', async () => { + const group = createDiagnosticsGroup(createContainer(createMarkerService())); + const tool = group.tools.find((item) => item.name === 'diagnostics_list')!; + + const result = await tool.execute({}); + + expect(result).toEqual({ + success: true, + result: { + diagnostics: [], + stats: { + errors: 1, + warnings: 2, + infos: 3, + unknowns: 4, + }, + total: 0, + truncated: false, + }, + }); + expect(() => JSON.stringify(result)).not.toThrow(); + }); +}); diff --git a/packages/ai-native/__test__/browser/webmcp-file-group.test.ts b/packages/ai-native/__test__/browser/webmcp-file-group.test.ts new file mode 100644 index 0000000000..9e20a2c88c --- /dev/null +++ b/packages/ai-native/__test__/browser/webmcp-file-group.test.ts @@ -0,0 +1,217 @@ +import { AppConfig } from '@opensumi/ide-core-browser'; +import { URI } from '@opensumi/ide-core-common'; +import { IFileServiceClient } from '@opensumi/ide-file-service'; + +import { WEBMCP_PROFILE_SETTING_ID, WebMcpGroupRegistry } from '../../src/browser/acp/webmcp-group-registry'; +import { createFileGroup } from '../../src/browser/acp/webmcp-groups/file.webmcp-group'; + +const workspaceDir = '/workspace/project'; +const mutationTools = ['file_create', 'file_write', 'file_copy', 'file_move', 'file_delete']; + +function uriOf(path: string): string { + return URI.file(`${workspaceDir}/${path}`).toString(); +} + +function createRegistry(profile: string): WebMcpGroupRegistry { + const registry = new WebMcpGroupRegistry(); + Object.defineProperty(registry, 'preferenceService', { + value: { + get: jest.fn((id: string, fallback: string) => (id === WEBMCP_PROFILE_SETTING_ID ? profile : fallback)), + }, + writable: true, + }); + registry.registerGroup(createFileGroup({} as any)); + return registry; +} + +function createMockFileService() { + const stats: Record = { + [URI.file(workspaceDir).toString()]: { + uri: URI.file(workspaceDir).toString(), + isDirectory: true, + isSymbolicLink: false, + }, + }; + + const fileService = { + getFileStat: jest.fn((uri: string) => Promise.resolve(stats[uri])), + createFile: jest.fn(async (uri: string, options?: { content?: string }) => { + const stat = { + uri, + isDirectory: false, + isSymbolicLink: false, + size: options?.content?.length ?? 0, + }; + stats[uri] = stat; + return stat; + }), + createFolder: jest.fn(async (uri: string) => { + const stat = { + uri, + isDirectory: true, + isSymbolicLink: false, + }; + stats[uri] = stat; + return stat; + }), + setContent: jest.fn(async (stat: any, content: string) => { + stats[stat.uri] = { + ...stat, + size: content.length, + }; + return stats[stat.uri]; + }), + copy: jest.fn(async (sourceUri: string, targetUri: string) => { + stats[targetUri] = { + ...stats[sourceUri], + uri: targetUri, + }; + return stats[targetUri]; + }), + move: jest.fn(async (sourceUri: string, targetUri: string) => { + stats[targetUri] = { + ...stats[sourceUri], + uri: targetUri, + }; + delete stats[sourceUri]; + return stats[targetUri]; + }), + delete: jest.fn(async (uri: string) => { + delete stats[uri]; + }), + }; + + return { fileService, stats }; +} + +function createContainer(fileService: ReturnType['fileService']) { + return { + get: jest.fn((token) => { + if (token === AppConfig) { + return { workspaceDir }; + } + if (token === IFileServiceClient) { + return fileService; + } + throw new Error('Unknown token'); + }), + } as any; +} + +function getTool(name: string, fileService = createMockFileService().fileService) { + const group = createFileGroup(createContainer(fileService)); + const tool = group.tools.find((item) => item.name === name); + if (!tool) { + throw new Error(`Missing tool: ${name}`); + } + return tool; +} + +describe('WebMCP file group', () => { + it('exposes mutation tools only in the full profile', () => { + expect( + createRegistry('default') + .getGroupDefinitions()[0] + .tools.map((tool) => tool.name), + ).not.toEqual(expect.arrayContaining(mutationTools)); + expect( + createRegistry('interactive') + .getGroupDefinitions()[0] + .tools.map((tool) => tool.name), + ).not.toEqual(expect.arrayContaining(mutationTools)); + expect( + createRegistry('full') + .getGroupDefinitions()[0] + .tools.map((tool) => tool.name), + ).toEqual(expect.arrayContaining(mutationTools)); + }); + + it('advertises BDD-compatible file mutation schemas', () => { + const group = createFileGroup({} as any); + const createSchema = group.tools.find((tool) => tool.name === 'file_create')?.inputSchema as any; + const copySchema = group.tools.find((tool) => tool.name === 'file_copy')?.inputSchema as any; + const moveSchema = group.tools.find((tool) => tool.name === 'file_move')?.inputSchema as any; + + expect(createSchema.properties.content).toMatchObject({ type: 'string' }); + expect(copySchema.required).toEqual(['sourcePath', 'targetPath']); + expect(copySchema.properties).toHaveProperty('sourcePath'); + expect(copySchema.properties).toHaveProperty('targetPath'); + expect(moveSchema.required).toEqual(['sourcePath', 'targetPath']); + expect(moveSchema.properties).toHaveProperty('sourcePath'); + expect(moveSchema.properties).toHaveProperty('targetPath'); + }); + + it('executes the reversible file mutation flow with workspace-relative paths', async () => { + const { fileService, stats } = createMockFileService(); + const group = createFileGroup(createContainer(fileService)); + const execute = async (name: string, params: Record) => { + const tool = group.tools.find((item) => item.name === name); + if (!tool) { + throw new Error(`Missing tool: ${name}`); + } + return tool.execute(params); + }; + + await expect(execute('file_create', { path: '.tmp/acp-bdd/source.txt', content: 'hello' })).resolves.toMatchObject({ + success: true, + }); + await expect(execute('file_write', { path: '.tmp/acp-bdd/source.txt', content: 'updated' })).resolves.toMatchObject( + { + success: true, + }, + ); + await expect( + execute('file_copy', { + sourcePath: '.tmp/acp-bdd/source.txt', + targetPath: '.tmp/acp-bdd/copy.txt', + }), + ).resolves.toMatchObject({ success: true }); + await expect( + execute('file_move', { + sourcePath: '.tmp/acp-bdd/copy.txt', + targetPath: '.tmp/acp-bdd/moved.txt', + }), + ).resolves.toMatchObject({ success: true }); + await expect(execute('file_delete', { path: '.tmp/acp-bdd/source.txt' })).resolves.toMatchObject({ + success: true, + }); + await expect(execute('file_delete', { path: '.tmp/acp-bdd/moved.txt' })).resolves.toMatchObject({ + success: true, + }); + + expect(fileService.createFile).toHaveBeenCalledWith(uriOf('.tmp/acp-bdd/source.txt'), { content: 'hello' }); + expect(fileService.setContent).toHaveBeenCalledWith( + expect.objectContaining({ uri: uriOf('.tmp/acp-bdd/source.txt') }), + 'updated', + ); + expect(fileService.copy).toHaveBeenCalledWith(uriOf('.tmp/acp-bdd/source.txt'), uriOf('.tmp/acp-bdd/copy.txt')); + expect(fileService.move).toHaveBeenCalledWith(uriOf('.tmp/acp-bdd/copy.txt'), uriOf('.tmp/acp-bdd/moved.txt')); + expect(stats[uriOf('.tmp/acp-bdd/source.txt')]).toBeUndefined(); + expect(stats[uriOf('.tmp/acp-bdd/moved.txt')]).toBeUndefined(); + }); + + it('rejects mutation targets outside the workspace', async () => { + const { fileService } = createMockFileService(); + const createTool = getTool('file_create', fileService); + const copyTool = getTool('file_copy', fileService); + + await expect(createTool.execute({ path: '../outside.txt', content: 'nope' })).resolves.toMatchObject({ + success: false, + error: 'INVALID_INPUT', + details: 'Path is outside of the workspace', + }); + await expect( + copyTool.execute({ + sourcePath: '.tmp/acp-bdd/source.txt', + targetPath: '../outside.txt', + }), + ).resolves.toMatchObject({ + success: false, + error: 'INVALID_INPUT', + details: 'Path is outside of the workspace', + }); + + expect(fileService.createFile).not.toHaveBeenCalled(); + expect(fileService.copy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/ai-native/__test__/browser/webmcp-file-workspace-path.test.ts b/packages/ai-native/__test__/browser/webmcp-file-workspace-path.test.ts new file mode 100644 index 0000000000..cfd9fb9b5a --- /dev/null +++ b/packages/ai-native/__test__/browser/webmcp-file-workspace-path.test.ts @@ -0,0 +1,112 @@ +import { URI } from '@opensumi/ide-core-common'; + +import { + resolveWorkspaceFilePath, + validateWorkspacePathAccess, + validateWritableWorkspaceTarget, +} from '../../src/browser/acp/webmcp-groups/file-workspace-path'; + +const workspaceDir = '/workspace/project'; + +function createFileService(stats: Record) { + return { + getFileStat: jest.fn((uri: string) => Promise.resolve(stats[uri])), + } as any; +} + +describe('WebMCP file workspace path policy', () => { + it('allows workspace-relative paths', () => { + const result = resolveWorkspaceFilePath(workspaceDir, 'src/index.ts'); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.absolutePath).toBe('/workspace/project/src/index.ts'); + expect(result.value.uri).toBe(URI.file('/workspace/project/src/index.ts').toString()); + } + }); + + it('allows absolute paths inside the workspace', () => { + const result = resolveWorkspaceFilePath(workspaceDir, '/workspace/project/README.md'); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.absolutePath).toBe('/workspace/project/README.md'); + } + }); + + it('rejects absolute paths outside the workspace', () => { + const result = resolveWorkspaceFilePath(workspaceDir, '/workspace/secret.txt'); + + expect(result).toMatchObject({ + ok: false, + message: 'Path is outside of the workspace', + }); + }); + + it('rejects path traversal outside the workspace', () => { + const result = resolveWorkspaceFilePath(workspaceDir, '../secret.txt'); + + expect(result).toMatchObject({ + ok: false, + message: 'Path is outside of the workspace', + }); + }); + + it('rejects URI strings and Windows drive-relative paths', () => { + expect(resolveWorkspaceFilePath(workspaceDir, 'file:///workspace/project/README.md')).toMatchObject({ + ok: false, + }); + expect(resolveWorkspaceFilePath('C:\\workspace\\project', 'C:secret.txt')).toMatchObject({ + ok: false, + message: 'Windows drive-relative paths are not supported', + }); + }); + + it('rejects reads through a symlink ancestor pointing outside the workspace', async () => { + const linkUri = URI.file('/workspace/project/link-out').toString(); + const fileService = createFileService({ + [linkUri]: { + uri: linkUri, + isDirectory: true, + isSymbolicLink: true, + realUri: URI.file('/outside').toString(), + }, + }); + const resolved = resolveWorkspaceFilePath(workspaceDir, 'link-out/file.txt'); + + expect(resolved.ok).toBe(true); + if (resolved.ok) { + await expect(validateWorkspacePathAccess(fileService, workspaceDir, resolved.value)).resolves.toMatchObject({ + ok: false, + message: 'Symbolic link target is outside of the workspace', + }); + } + }); + + it('rejects writes through a symlink parent pointing outside the workspace', async () => { + const linkUri = URI.file('/workspace/project/link-out').toString(); + const fileService = createFileService({ + [linkUri]: { + uri: linkUri, + isDirectory: true, + isSymbolicLink: true, + realUri: URI.file('/outside').toString(), + }, + }); + const resolved = resolveWorkspaceFilePath(workspaceDir, 'link-out/new-file.txt'); + + expect(resolved.ok).toBe(true); + if (resolved.ok) { + await expect(validateWritableWorkspaceTarget(fileService, workspaceDir, resolved.value)).resolves.toMatchObject({ + ok: false, + message: 'Symbolic link target is outside of the workspace', + }); + } + }); + + it('does not accept authorization flags as a workspace escape hatch', () => { + const result = resolveWorkspaceFilePath(workspaceDir, '/outside/authorized.txt'); + + expect(result.ok).toBe(false); + }); +}); diff --git a/packages/ai-native/__test__/browser/webmcp-group-registry.test.ts b/packages/ai-native/__test__/browser/webmcp-group-registry.test.ts new file mode 100644 index 0000000000..c820f6efea --- /dev/null +++ b/packages/ai-native/__test__/browser/webmcp-group-registry.test.ts @@ -0,0 +1,140 @@ +import { + WEBMCP_PROFILE_QUERY_PARAM, + WEBMCP_PROFILE_SETTING_ID, + WebMcpGroupRegistry, + canUseWebMcpProfileQueryOverride, + getWebMcpProfileFromSearch, +} from '../../src/browser/acp/webmcp-group-registry'; +import { createEditorGroup } from '../../src/browser/acp/webmcp-groups/editor.webmcp-group'; +import { createTerminalGroup } from '../../src/browser/acp/webmcp-groups/terminal.webmcp-group'; + +describe('WebMCP group registry policy', () => { + function createRegistryWithProfile(profile: string) { + const registry = new WebMcpGroupRegistry(); + Object.defineProperty(registry, 'preferenceService', { + value: { + get: jest.fn((id: string, fallback: string) => (id === WEBMCP_PROFILE_SETTING_ID ? profile : fallback)), + }, + writable: true, + }); + return registry; + } + + function createRegistry(profile: string) { + const registry = createRegistryWithProfile(profile); + registry.registerGroup({ + name: 'terminal', + description: 'Terminal', + defaultLoaded: true, + tools: [ + { + name: 'terminal_read_output', + description: 'Read output', + riskLevel: 'read', + inputSchema: {}, + execute: jest.fn().mockResolvedValue({ success: true }), + }, + { + name: 'terminal_run_command', + description: 'Run command', + riskLevel: 'shell', + profiles: ['interactive', 'full'], + inputSchema: {}, + execute: jest.fn().mockResolvedValue({ success: true }), + }, + { + name: 'terminal_internal_write', + description: 'Hidden write', + riskLevel: 'write', + exposedByDefault: false, + profiles: ['full'], + inputSchema: {}, + execute: jest.fn().mockResolvedValue({ success: true }), + }, + ], + }); + return registry; + } + + it('parses runtime profile overrides from URL search params', () => { + expect(getWebMcpProfileFromSearch(`?${WEBMCP_PROFILE_QUERY_PARAM}=interactive`)).toBe('interactive'); + expect(getWebMcpProfileFromSearch(`?${WEBMCP_PROFILE_SETTING_ID}=full`)).toBe('full'); + expect(getWebMcpProfileFromSearch(`?${WEBMCP_PROFILE_QUERY_PARAM}=invalid&${WEBMCP_PROFILE_SETTING_ID}=full`)).toBe( + 'full', + ); + expect(getWebMcpProfileFromSearch(`?${WEBMCP_PROFILE_QUERY_PARAM}=invalid`)).toBeUndefined(); + expect(getWebMcpProfileFromSearch('')).toBeUndefined(); + }); + + it('only allows URL profile overrides on loopback hosts', () => { + expect(canUseWebMcpProfileQueryOverride('localhost')).toBe(true); + expect(canUseWebMcpProfileQueryOverride('127.0.0.1')).toBe(true); + expect(canUseWebMcpProfileQueryOverride('::1')).toBe(true); + expect(canUseWebMcpProfileQueryOverride('example.com')).toBe(false); + }); + + it('does not expose or execute shell tools in the default profile', async () => { + const registry = createRegistry('default'); + + expect(registry.getGroupDefinitions()[0].tools.map((tool) => tool.name)).toEqual(['terminal_read_output']); + await expect(registry.executeTool('terminal', 'terminal_run_command', {})).resolves.toMatchObject({ + success: false, + error: 'PERMISSION_DENIED', + }); + }); + + it('executes shell tools in the interactive profile', async () => { + const registry = createRegistry('interactive'); + + expect(registry.getGroupDefinitions()[0].tools.map((tool) => tool.name)).toEqual([ + 'terminal_read_output', + 'terminal_run_command', + ]); + await expect(registry.executeTool('terminal', 'terminal_run_command', {})).resolves.toMatchObject({ + success: true, + }); + }); + + it('does not execute tools hidden by exposedByDefault false', async () => { + const registry = createRegistry('full'); + + await expect(registry.executeTool('terminal', 'terminal_internal_write', {})).resolves.toMatchObject({ + success: false, + error: 'PERMISSION_DENIED', + }); + }); + + it('prefers the URL profile override over the persisted preference', async () => { + const previousUrl = window.location.href; + window.history.pushState({}, '', `/?${WEBMCP_PROFILE_QUERY_PARAM}=interactive`); + try { + const registry = createRegistry('default'); + + expect(registry.getGroupDefinitions()[0].tools.map((tool) => tool.name)).toEqual([ + 'terminal_read_output', + 'terminal_run_command', + ]); + await expect(registry.executeTool('terminal', 'terminal_run_command', {})).resolves.toMatchObject({ + success: true, + }); + } finally { + window.history.pushState({}, '', previousUrl); + } + }); + + it('exposes editor save/format and terminal disposal only in the full profile', () => { + const defaultRegistry = createRegistryWithProfile('default'); + const fullRegistry = createRegistryWithProfile('full'); + const container = {} as any; + for (const registry of [defaultRegistry, fullRegistry]) { + registry.registerGroup(createEditorGroup(container)); + registry.registerGroup(createTerminalGroup(container)); + } + + const defaultTools = defaultRegistry.getGroupDefinitions().flatMap((group) => group.tools.map((tool) => tool.name)); + expect(defaultTools).not.toEqual(expect.arrayContaining(['editor_format', 'editor_save', 'terminal_dispose'])); + + const fullTools = fullRegistry.getGroupDefinitions().flatMap((group) => group.tools.map((tool) => tool.name)); + expect(fullTools).toEqual(expect.arrayContaining(['editor_format', 'editor_save', 'terminal_dispose'])); + }); +}); diff --git a/packages/ai-native/__test__/browser/webmcp-opensumi-mcp-group.test.ts b/packages/ai-native/__test__/browser/webmcp-opensumi-mcp-group.test.ts new file mode 100644 index 0000000000..826fa9d5ab --- /dev/null +++ b/packages/ai-native/__test__/browser/webmcp-opensumi-mcp-group.test.ts @@ -0,0 +1,46 @@ +import { AIBackSerivcePath } from '@opensumi/ide-core-common'; + +import { createOpenSumiMcpGroup } from '../../src/browser/acp/webmcp-groups/opensumi-mcp.webmcp-group'; + +describe('OpenSumi MCP WebMCP group', () => { + it('returns a stable built-in MCP connection descriptor from AIBackService', async () => { + const connection = { + name: 'opensumi-ide', + type: 'http', + transport: 'streamable-http', + url: 'http://127.0.0.1:12345/mcp/token', + redactedUrl: 'http://127.0.0.1:12345/mcp/', + headers: [], + }; + const aiBackService = { + getOpenSumiMcpServerConnection: jest.fn().mockResolvedValue(connection), + }; + const group = createOpenSumiMcpGroup({ + get: jest.fn((token) => { + if (token === AIBackSerivcePath) { + return aiBackService; + } + throw new Error('unknown token'); + }), + } as any); + + const result = await group.tools[0].execute({}); + + expect(aiBackService.getOpenSumiMcpServerConnection).toHaveBeenCalled(); + expect(result).toEqual({ + success: true, + result: connection, + }); + }); + + it('returns SERVICE_UNAVAILABLE when AIBackService does not expose the connection method', async () => { + const group = createOpenSumiMcpGroup({ + get: jest.fn(() => ({})), + } as any); + + await expect(group.tools[0].execute({})).resolves.toMatchObject({ + success: false, + error: 'SERVICE_UNAVAILABLE', + }); + }); +}); diff --git a/packages/ai-native/__test__/browser/webmcp-tool-naming.test.ts b/packages/ai-native/__test__/browser/webmcp-tool-naming.test.ts new file mode 100644 index 0000000000..d4e5e73b44 --- /dev/null +++ b/packages/ai-native/__test__/browser/webmcp-tool-naming.test.ts @@ -0,0 +1,32 @@ +import { createAcpChatGroup } from '../../src/browser/acp/webmcp-groups/acp-chat.webmcp-group'; +import { createDiagnosticsGroup } from '../../src/browser/acp/webmcp-groups/diagnostics.webmcp-group'; +import { createEditorGroup } from '../../src/browser/acp/webmcp-groups/editor.webmcp-group'; +import { createFileGroup } from '../../src/browser/acp/webmcp-groups/file.webmcp-group'; +import { createOpenSumiMcpGroup } from '../../src/browser/acp/webmcp-groups/opensumi-mcp.webmcp-group'; +import { createSearchGroup } from '../../src/browser/acp/webmcp-groups/search.webmcp-group'; +import { createTerminalGroup } from '../../src/browser/acp/webmcp-groups/terminal.webmcp-group'; +import { createWorkspaceGroup } from '../../src/browser/acp/webmcp-groups/workspace.webmcp-group'; + +const LOWER_SNAKE_TOOL_NAME = /^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/; + +describe('WebMCP tool naming contract', () => { + it('registers only lower snake case external tool names', () => { + const container = {} as any; + const groups = [ + createOpenSumiMcpGroup(container), + createWorkspaceGroup(container), + createSearchGroup(container), + createDiagnosticsGroup(container), + createFileGroup(container), + createTerminalGroup(container), + createEditorGroup(container), + createAcpChatGroup(container), + ]; + + const invalidToolNames = groups + .flatMap((group) => group.tools.map((tool) => ({ group: group.name, name: tool.name }))) + .filter(({ name }) => !LOWER_SNAKE_TOOL_NAME.test(name) || name.includes('/') || /[A-Z]/.test(name)); + + expect(invalidToolNames).toEqual([]); + }); +}); 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..03cfc92163 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,13 @@ jest.mock('@opensumi/di', () => { }; }); -import { AgentProcessConfig } from '@opensumi/ide-core-common'; +import { DEFAULT_ACP_THREAD_POOL_SIZE } from '@opensumi/ide-core-common/lib/settings/ai-native'; 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,409 +30,1971 @@ 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: [], +}; + +const SMALL_THREAD_POOL_SIZE = 3; + +const mockAgentProcessConfigWithSmallPool = { + ...mockAgentProcessConfig, + threadPoolSize: SMALL_THREAD_POOL_SIZE, }; -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; + getSessionNotifications: jest.Mock; + getSessionState: 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; + setSessionMode: jest.Mock; + setSessionConfigOption: jest.Mock; + unstable_setSessionModel: jest.Mock; + reset: jest.Mock; + dispose: jest.Mock; + onEvent: jest.Mock; + _fireEvent: (event: any) => void; + _eventListeners: Array<(event: any) => void>; +} + +function flushAsyncWork(): Promise { + return new Promise((resolve) => setImmediate(resolve)); +} + +function createDeferred(): { + promise: Promise; + resolve: (value?: T | PromiseLike) => void; + reject: (reason?: unknown) => void; +} { + let resolve!: (value?: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +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([]), + getSessionNotifications: jest.fn().mockReturnValue([]), + getSessionState: jest.fn().mockReturnValue({ + notifications: [], + entries: [], + modes: [], + }), + 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(), + setSessionMode: jest.fn().mockResolvedValue(undefined), + setSessionConfigOption: jest.fn().mockResolvedValue(undefined), + unstable_setSessionModel: 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'); + describe('getSessionMcpServers()', () => { + it('should start and return the built-in OpenSumi MCP server connection descriptor', async () => { + const { service } = createService(); + const connection = { + name: 'opensumi-ide', + type: 'http', + transport: 'streamable-http', + url: 'http://127.0.0.1:12345/mcp/token', + redactedUrl: 'http://127.0.0.1:12345/mcp/', + headers: [], + }; + const opensumiMcpHttpServer = { + start: jest.fn().mockResolvedValue(undefined), + getConnectionInfo: jest.fn().mockReturnValue(connection), + }; + (service as any).opensumiMcpHttpServer = opensumiMcpHttpServer; + + await expect(service.getOpenSumiMcpServerConnection('client-full')).resolves.toBe(connection); + expect(opensumiMcpHttpServer.start).toHaveBeenCalled(); + expect(opensumiMcpHttpServer.getConnectionInfo).toHaveBeenCalledWith('client-full'); + }); + + it('should append the built-in OpenSumi MCP server when the agent supports HTTP MCP', async () => { + const thread = createMockThread({ + agentCapabilities: { + mcpCapabilities: { + http: true, + sse: true, + }, + }, + }); + const mockFactory = jest.fn().mockReturnValue(thread); + const service = setupServiceWithMockFactory(mockFactory); + const opensumiMcpHttpServer = { + getServerName: jest.fn().mockReturnValue('opensumi-ide'), + start: jest.fn().mockResolvedValue(undefined), + getUrl: jest.fn().mockReturnValue('http://127.0.0.1:12345/mcp/token'), + }; + (service as any).opensumiMcpHttpServer = opensumiMcpHttpServer; + + const servers = await (service as any).getSessionMcpServers(thread, { + ...mockAgentProcessConfig, + mcpServers: [ + { + name: 'external-http', + type: 'http', + url: 'http://127.0.0.1:9999/mcp', + headers: [], + }, + ], + }); + + expect(opensumiMcpHttpServer.start).toHaveBeenCalled(); + expect(servers).toEqual([ + { + name: 'external-http', + type: 'http', + url: 'http://127.0.0.1:9999/mcp', + headers: [], + }, + { + name: 'opensumi-ide', + type: 'http', + url: 'http://127.0.0.1:12345/mcp/token', + headers: [], + }, + ]); + }); + + it('should not append the built-in OpenSumi MCP server without HTTP MCP support', async () => { + const thread = createMockThread({ + agentCapabilities: { + mcpCapabilities: { + http: false, + sse: true, + }, + }, + }); + const mockFactory = jest.fn().mockReturnValue(thread); + const service = setupServiceWithMockFactory(mockFactory); + const opensumiMcpHttpServer = { + getServerName: jest.fn().mockReturnValue('opensumi-ide'), + start: jest.fn().mockResolvedValue(undefined), + getUrl: jest.fn().mockReturnValue('http://127.0.0.1:12345/mcp/token'), + }; + (service as any).opensumiMcpHttpServer = opensumiMcpHttpServer; + + const servers = await (service as any).getSessionMcpServers(thread, { + ...mockAgentProcessConfig, + mcpServers: [], + }); + + expect(opensumiMcpHttpServer.start).not.toHaveBeenCalled(); + expect(servers).toEqual([]); + }); + + it('should not append the built-in OpenSumi MCP server when WebMCP is disabled', async () => { + const thread = createMockThread({ + agentCapabilities: { + mcpCapabilities: { + http: true, + sse: true, + }, + }, + }); + const mockFactory = jest.fn().mockReturnValue(thread); + const service = setupServiceWithMockFactory(mockFactory); + const opensumiMcpHttpServer = { + getServerName: jest.fn().mockReturnValue('opensumi-ide'), + start: jest.fn().mockResolvedValue(undefined), + getUrl: jest.fn().mockReturnValue('http://127.0.0.1:12345/mcp/token'), + }; + (service as any).opensumiMcpHttpServer = opensumiMcpHttpServer; + + const externalServer = { + name: 'external-http', + type: 'http', + url: 'http://127.0.0.1:9999/mcp', + headers: [], + }; + const servers = await (service as any).getSessionMcpServers(thread, { + ...mockAgentProcessConfig, + webMcp: { + enabled: false, + }, + mcpServers: [externalServer], + }); + + expect(opensumiMcpHttpServer.start).not.toHaveBeenCalled(); + expect(servers).toEqual([externalServer]); + }); + }); + + // ----------------------------------------------------------------------- + // 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.newSession).toHaveBeenCalled(); + }); + + it('should create a session with empty commands when available_commands_update times out', async () => { + jest.useFakeTimers(); + const { service, thread } = createServiceWithAutoEvents(); + + const resultPromise = service.createSession(mockAgentProcessConfig); + await jest.advanceTimersByTimeAsync(5000); + + const result = await resultPromise; + + expect(result.sessionId).toBe('new-session-1'); + expect(result.availableCommands).toEqual([]); + expect(thread.dispose).not.toHaveBeenCalled(); + }); + + it('should throw when thread pool is full and no idle threads', async () => { + const { service, thread } = createServiceWithAutoEvents(); + + // Fill the pool with max threads + const createdThreads: MockThread[] = []; + for (let i = 0; i < SMALL_THREAD_POOL_SIZE; 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(mockAgentProcessConfigWithSmallPool); + } + + // Now try to create another session - should fail + const failThread = createMockThread(); + (service as any).threadFactory.mockReturnValue(failThread); + await expect(service.createSession(mockAgentProcessConfigWithSmallPool)).rejects.toThrow('Thread pool is full'); + }); + + it('should recycle the least recently used reusable thread when pool is full', async () => { + const { service, mockFactory } = createServiceWithAutoEvents(); + const threads: MockThread[] = []; + + for (let i = 0; i < 3; i++) { + const t = createMockThread({ + threadId: `thread-${i}`, + getStatus: jest.fn().mockReturnValue('awaiting_prompt'), + newSession: jest.fn().mockResolvedValue({ sessionId: `session-${i}` }), + 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); + mockFactory.mockReturnValueOnce(t); + await service.createSession(mockAgentProcessConfigWithSmallPool); + } + + threads[0].newSession.mockResolvedValueOnce({ sessionId: 'session-3' }); + const result = await service.createSession(mockAgentProcessConfigWithSmallPool); + + expect(result.sessionId).toBe('session-3'); + expect(mockFactory).toHaveBeenCalledTimes(3); + expect(threads[0].newSession).toHaveBeenCalledTimes(2); + expect((service as any).sessions.has('session-0')).toBe(false); + expect((service as any).sessions.get('session-3')).toBe(threads[0]); + }); + + it('should not let loadSession reuse a thread reserved by createSession', async () => { + const initializeGate = createDeferred(); + const creatingThread = createMockThread({ + threadId: 'creating-thread', + initialize: jest.fn().mockReturnValue(initializeGate.promise), + newSession: jest.fn().mockResolvedValue({ sessionId: 'created-session' }), + getStatus: jest.fn().mockReturnValue('idle'), + }); + const loadingThread = createMockThread({ + threadId: 'loading-thread', + getStatus: jest.fn().mockReturnValue('idle'), + loadSession: jest.fn().mockResolvedValue({ sessionId: 'loaded-session' }), + }); + const mockFactory = jest.fn().mockReturnValueOnce(creatingThread).mockReturnValueOnce(loadingThread); + const service = setupServiceWithMockFactory(mockFactory); + + const createPromise = service.createSession(mockAgentProcessConfig); + await flushAsyncWork(); + + const loadPromise = service.loadSession('loaded-session', mockAgentProcessConfig); + await flushAsyncWork(); + + expect(mockFactory).toHaveBeenCalledTimes(2); + expect((service as any).sessions.get('loaded-session')).toBe(loadingThread); + expect(creatingThread.loadSession).not.toHaveBeenCalled(); + + initializeGate.resolve({ protocolVersion: 1, agentCapabilities: {} }); + creatingThread._fireEvent({ + type: 'session_notification', + notification: { + sessionId: 'created-session', + update: { sessionUpdate: 'available_commands_update', availableCommands: [] }, + }, + }); + + const [createResult, loadResult] = await Promise.all([createPromise, loadPromise]); + + expect(loadingThread.loadSession).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: 'loaded-session', cwd: mockAgentProcessConfig.cwd }), + ); + expect(createResult.sessionId).toBe('created-session'); + expect(loadResult.sessionId).toBe('loaded-session'); + expect((service as any).sessions.get('created-session')).toBe(creatingThread); + expect((service as any).sessions.get('loaded-session')).toBe(loadingThread); + }); + + 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(); + }); + + it('should apply valid default mode, model, and config options after creating a session', async () => { + jest.useFakeTimers(); + const { service, thread } = createServiceWithAutoEvents(); + thread.getSessionState.mockReturnValue({ + notifications: [], + entries: [], + modes: [{ id: 'plan', name: 'Plan' }], + models: [{ modelId: 'gpt-5', name: 'GPT-5' }], + configOptions: [ + { + id: 'permission', + options: [{ value: 'acceptEdits' }, { value: 'ask' }], + }, + { + id: 'thinking', + }, + ], + }); + + const resultPromise = service.createSession({ + ...mockAgentProcessConfig, + defaultMode: 'plan', + defaultModel: 'gpt-5', + defaultConfigOptions: { + permission: 'acceptEdits', + thinking: true, + }, + }); + await jest.advanceTimersByTimeAsync(5000); + const result = await resultPromise; + + expect(result.sessionId).toBe('new-session-1'); + expect(thread.setSessionMode).toHaveBeenCalledWith({ sessionId: 'new-session-1', modeId: 'plan' }); + expect(thread.unstable_setSessionModel).toHaveBeenCalledWith({ sessionId: 'new-session-1', model: 'gpt-5' }); + expect(thread.setSessionConfigOption).toHaveBeenCalledWith({ + sessionId: 'new-session-1', + configId: 'permission', + value: 'acceptEdits', + }); + expect(thread.setSessionConfigOption).toHaveBeenCalledWith({ + sessionId: 'new-session-1', + configId: 'thinking', + value: true, + }); + }); + + it('should warn and continue when default session options are invalid', async () => { + jest.useFakeTimers(); + const { service, thread } = createServiceWithAutoEvents(); + thread.getSessionState.mockReturnValue({ + notifications: [], + entries: [], + modes: [{ id: 'code', name: 'Code' }], + models: [{ modelId: 'claude-sonnet', name: 'Claude Sonnet' }], + configOptions: [ + { + id: 'permission', + options: [{ value: 'ask' }], + }, + ], + }); + + const resultPromise = service.createSession({ + ...mockAgentProcessConfig, + defaultMode: 'plan', + defaultModel: 'gpt-5', + defaultConfigOptions: { + permission: 'acceptEdits', + missing: 'value', + }, + }); + await jest.advanceTimersByTimeAsync(5000); + const result = await resultPromise; + + expect(result.sessionId).toBe('new-session-1'); + expect(thread.setSessionMode).not.toHaveBeenCalled(); + expect(thread.unstable_setSessionModel).not.toHaveBeenCalled(); + expect(thread.setSessionConfigOption).not.toHaveBeenCalled(); + expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('Invalid defaultMode')); + expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('Invalid defaultModel')); + expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('Invalid defaultConfigOptions value')); + expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('Invalid defaultConfigOptions key')); }); }); + // ----------------------------------------------------------------------- + // 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(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 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(result.sessionId).toBe('existing-session-id'); + expect(thread.loadSession).toHaveBeenCalledWith(expect.objectContaining({ sessionId: 'existing-session-id' })); + }); + + it('should return native agent replay notifications as historyUpdates', async () => { + const nativeHistory = [ + { + sessionId: 'existing-session-id', + update: { + sessionUpdate: 'tool_call_update', + toolCallId: 'edit-1', + status: 'completed', + content: [{ type: 'diff', path: 'src/index.ts' }], + rawOutput: { changedFiles: ['src/index.ts'] }, + }, + }, + { + sessionId: 'existing-session-id', + update: { + sessionUpdate: 'usage_update', + used: 120, + size: 2000, + }, + }, + ]; + const thread = createMockThread({ + initialized: true, + getStatus: jest.fn().mockReturnValue('idle'), + getSessionNotifications: jest.fn().mockReturnValue(nativeHistory), + 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(result.historyUpdates).toEqual(nativeHistory); + }); + + it('should not synthesize historyUpdates from local entries', async () => { + const thread = createMockThread({ + initialized: true, + getStatus: jest.fn().mockReturnValue('idle'), + getEntries: jest.fn().mockReturnValue([ + { + type: 'user_message', + data: { id: 'msg-1', content: 'local prompt', timestamp: 1 }, + }, + ]), + getSessionNotifications: jest.fn().mockReturnValue([]), + 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(result.historyUpdates).toEqual([]); + }); + + it('should apply default session options after loading a session', async () => { + const thread = createMockThread({ + initialized: true, + getStatus: jest.fn().mockReturnValue('idle'), + getSessionState: jest.fn().mockReturnValue({ + notifications: [], + entries: [], + modes: [{ id: 'code', name: 'Code' }], + models: [{ modelId: 'gpt-5-mini', name: 'GPT-5 Mini' }], + configOptions: [{ id: 'approval', options: [{ value: 'on-request' }] }], + }), + onEvent: jest.fn(() => ({ dispose: jest.fn() })), + }); + const mockFactory = jest.fn().mockReturnValue(thread); + const service = setupServiceWithMockFactory(mockFactory); + + await service.loadSession('existing-session-id', { + ...mockAgentProcessConfig, + defaultMode: 'code', + defaultModel: 'gpt-5-mini', + defaultConfigOptions: { + approval: 'on-request', + }, + }); + + expect(thread.setSessionMode).toHaveBeenCalledWith({ sessionId: 'existing-session-id', modeId: 'code' }); + expect(thread.unstable_setSessionModel).toHaveBeenCalledWith({ + sessionId: 'existing-session-id', + model: 'gpt-5-mini', + }); + expect(thread.setSessionConfigOption).toHaveBeenCalledWith({ + sessionId: 'existing-session-id', + configId: 'approval', + value: 'on-request', + }); + }); + + it('should join an in-flight load instead of returning a half-loaded thread', async () => { + const loadGate = createDeferred(); + let loaded = false; + const thread = createMockThread({ + initialized: true, + getStatus: jest.fn().mockReturnValue('idle'), + loadSession: jest.fn(async () => { + await loadGate.promise; + loaded = true; + return { sessionId: 'shared-session' }; + }), + getSessionNotifications: jest.fn(() => + loaded + ? [ + { + sessionId: 'shared-session', + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'Loaded history' }, + }, + }, + ] + : [], + ), + onEvent: jest.fn(() => ({ dispose: jest.fn() })), + }); + const mockFactory = jest.fn().mockReturnValue(thread); + const service = setupServiceWithMockFactory(mockFactory); + + const firstLoad = service.loadSession('shared-session', mockAgentProcessConfig); + await flushAsyncWork(); + expect(thread.loadSession).toHaveBeenCalledTimes(1); + + let secondResolved = false; + const secondLoad = service.loadSession('shared-session', mockAgentProcessConfig).then((result) => { + secondResolved = true; + return result; + }); + + await flushAsyncWork(); + expect(thread.loadSession).toHaveBeenCalledTimes(1); + expect(secondResolved).toBe(false); + + loadGate.resolve(); + const [firstResult, secondResult] = await Promise.all([firstLoad, secondLoad]); + + expect(firstResult.historyUpdates).toHaveLength(1); + expect(secondResult.historyUpdates).toHaveLength(1); + expect(secondResult.historyUpdates[0].update).toEqual( + expect.objectContaining({ + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'Loaded history' }, + }), ); - expect(mockCliClientService.setTransport).toHaveBeenCalled(); - expect(mockCliClientService.initialize).toHaveBeenCalled(); - expect(mockCliClientService.newSession).toHaveBeenCalledWith({ - cwd: '/test/workspace', - mcpServers: [], + }); + + it('should throw when pool is full and no idle thread', async () => { + const { service } = createServiceWithAutoEvents(); + + // Fill the pool + for (let i = 0; i < SMALL_THREAD_POOL_SIZE; 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(mockAgentProcessConfigWithSmallPool); + } + + await expect(service.loadSession('new-session', mockAgentProcessConfigWithSmallPool)).rejects.toThrow( + 'Thread pool is full', + ); + }); + + it('should load a new session by recycling the least recently used reusable thread', async () => { + const { service, mockFactory } = createServiceWithAutoEvents(); + const threads: MockThread[] = []; + + for (let i = 0; i < 3; i++) { + const t = createMockThread({ + threadId: `thread-${i}`, + getStatus: jest.fn().mockReturnValue('awaiting_prompt'), + newSession: jest.fn().mockResolvedValue({ sessionId: `session-${i}` }), + loadSession: jest.fn().mockResolvedValue({ sessionId: 'session-3' }), + 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); + mockFactory.mockReturnValueOnce(t); + await service.createSession(mockAgentProcessConfigWithSmallPool); + } + + const result = await service.loadSession('session-3', mockAgentProcessConfigWithSmallPool); + + expect(result.sessionId).toBe('session-3'); + expect(mockFactory).toHaveBeenCalledTimes(3); + expect(threads[0].loadSession).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: 'session-3', cwd: mockAgentProcessConfig.cwd }), + ); + expect((service as any).sessions.has('session-0')).toBe(false); + expect((service as any).sessions.get('session-3')).toBe(threads[0]); + }); + + it('should reserve a recycled thread before async cleanup so concurrent loads cannot reuse it', async () => { + const { service, mockFactory } = createServiceWithAutoEvents(); + const threads: MockThread[] = []; + + for (let i = 0; i < 3; i++) { + const t = createMockThread({ + threadId: `thread-${i}`, + getStatus: jest.fn().mockReturnValue('awaiting_prompt'), + newSession: jest.fn().mockResolvedValue({ sessionId: `session-${i}` }), + loadSession: jest.fn(async (params) => ({ sessionId: params.sessionId })), + 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); + mockFactory.mockReturnValueOnce(t); + await service.createSession(mockAgentProcessConfigWithSmallPool); + } + + const firstReleaseGate = createDeferred(); + mockTerminalHandler.releaseSessionTerminals.mockImplementation(async (sessionId: string) => { + if (sessionId === 'session-0') { + await firstReleaseGate.promise; + } }); - expect(result.sessionId).toBe('test-session-123'); - expect(result.status).toBe('ready'); + + const firstLoad = service.loadSession('session-3', mockAgentProcessConfigWithSmallPool); + await flushAsyncWork(); + expect(mockTerminalHandler.releaseSessionTerminals).toHaveBeenCalledWith('session-0'); + + const secondLoad = service.loadSession('session-4', mockAgentProcessConfigWithSmallPool); + await flushAsyncWork(); + expect(mockTerminalHandler.releaseSessionTerminals).toHaveBeenCalledWith('session-1'); + + firstReleaseGate.resolve(); + await Promise.all([firstLoad, secondLoad]); + + expect(threads[0].loadSession).toHaveBeenCalledTimes(1); + expect(threads[0].loadSession).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: 'session-3', cwd: mockAgentProcessConfig.cwd }), + ); + expect(threads[1].loadSession).toHaveBeenCalledTimes(1); + expect(threads[1].loadSession).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: 'session-4', cwd: mockAgentProcessConfig.cwd }), + ); + expect((service as any).sessions.get('session-3')).toBe(threads[0]); + expect((service as any).sessions.get('session-4')).toBe(threads[1]); }); + }); + + describe('loadSessionOrNew()', () => { + it('should rebind service state when fallback creates a different session id', async () => { + const thread = createMockThread({ + initialized: true, + getStatus: jest.fn().mockReturnValue('idle'), + loadSessionOrNew: jest.fn().mockResolvedValue({ sessionId: 'actual-session-id' }), + onEvent: jest.fn(() => ({ dispose: jest.fn() })), + }); + const mockFactory = jest.fn().mockReturnValue(thread); + const service = setupServiceWithMockFactory(mockFactory); + + const result = await service.loadSessionOrNew('missing-session-id', mockAgentProcessConfig); + + expect(result.sessionId).toBe('actual-session-id'); + expect((service as any).sessions.has('missing-session-id')).toBe(false); + expect((service as any).sessions.get('actual-session-id')).toBe(thread); + expect(mockPermissionRouting.unregisterSession).toHaveBeenCalledWith('missing-session-id'); + expect(mockPermissionRouting.registerSession).toHaveBeenCalledWith('actual-session-id'); + }); + + it('should join a pending loadSessionOrNew request for the same session', async () => { + const loadGate = createDeferred<{ sessionId: string }>(); + const thread = createMockThread({ + initialized: true, + getStatus: jest.fn().mockReturnValue('idle'), + loadSessionOrNew: jest.fn().mockReturnValue(loadGate.promise), + onEvent: jest.fn(() => ({ dispose: jest.fn() })), + }); + const mockFactory = jest.fn().mockReturnValue(thread); + const service = setupServiceWithMockFactory(mockFactory); - it('should return cached sessionInfo if already initialized', async () => { - const service = createService(); - const first = await service.initializeAgent(mockAgentProcessConfig); - const second = await service.initializeAgent(mockAgentProcessConfig); + const firstLoad = service.loadSessionOrNew('pending-session-id', mockAgentProcessConfig); + await flushAsyncWork(); - expect(first).toBe(second); - expect(mockProcessManager.startAgent).toHaveBeenCalledTimes(1); - expect(mockCliClientService.newSession).toHaveBeenCalledTimes(1); + const secondLoad = service.loadSessionOrNew('pending-session-id', mockAgentProcessConfig); + loadGate.resolve({ sessionId: 'pending-session-id' }); + const [firstResult, secondResult] = await Promise.all([firstLoad, secondLoad]); + + expect(firstResult.sessionId).toBe('pending-session-id'); + expect(secondResult.sessionId).toBe('pending-session-id'); + expect(thread.loadSessionOrNew).toHaveBeenCalledTimes(1); + expect((service as any).sessionRefCounts.get('pending-session-id')).toBe(2); + }); + + it('should release recycled thread reservation after loadSessionOrNew registers a pending load', async () => { + const { service, mockFactory } = createServiceWithAutoEvents(); + const threads: MockThread[] = []; + + for (let i = 0; i < 3; i++) { + const t = createMockThread({ + threadId: `thread-${i}`, + getStatus: jest.fn().mockReturnValue('awaiting_prompt'), + newSession: jest.fn().mockResolvedValue({ sessionId: `session-${i}` }), + loadSessionOrNew: jest.fn().mockResolvedValue({ sessionId: 'session-3' }), + 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); + mockFactory.mockReturnValueOnce(t); + await service.createSession(mockAgentProcessConfigWithSmallPool); + } + + await service.loadSessionOrNew('session-3', mockAgentProcessConfigWithSmallPool); + + expect(threads[0].loadSessionOrNew).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: 'session-3', cwd: mockAgentProcessConfig.cwd }), + ); + expect((service as any).reservedThreads.has(threads[0])).toBe(false); }); }); + // ----------------------------------------------------------------------- + // 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 cannot be loaded', async () => { + const thread = createMockThread({ + loadSession: jest.fn().mockRejectedValue(new Error('Session not found')), + }); + const service = setupServiceWithMockFactory(jest.fn().mockReturnValue(thread)); + const stream = service.sendMessage({ prompt: 'hello', sessionId: 'nonexistent' }, mockAgentProcessConfig); const errors: Error[] = []; stream.onError((e) => errors.push(e)); + await flushAsyncWork(); expect(errors.length).toBe(1); - expect(errors[0].message).toBe('Agent process not initialized'); + expect(errors[0].message).toContain('Session not found'); + }); + + it('should reload an LRU-evicted session before sending a message', async () => { + const { service, mockFactory } = createServiceWithAutoEvents(); + const threads: MockThread[] = []; + + for (let i = 0; i < 3; i++) { + const threadIndex = i; + const t = createMockThread({ + threadId: `thread-${threadIndex}`, + getStatus: jest.fn().mockReturnValue('awaiting_prompt'), + newSession: jest.fn().mockResolvedValue({ sessionId: `session-${threadIndex}` }), + loadSession: jest.fn().mockResolvedValue({ sessionId: 'session-0' }), + onEvent: jest.fn((cb: any) => { + setTimeout(() => { + cb({ + type: 'session_notification', + notification: { + sessionId: `session-${threadIndex}`, + update: { sessionUpdate: 'available_commands_update', availableCommands: [] }, + }, + }); + }, 10); + return { dispose: jest.fn() }; + }), + }); + threads.push(t); + mockFactory.mockReturnValueOnce(t); + await service.createSession(mockAgentProcessConfigWithSmallPool); + } + + threads[0].newSession.mockResolvedValueOnce({ sessionId: 'session-3' }); + await service.createSession(mockAgentProcessConfigWithSmallPool); + + expect((service as any).sessions.has('session-0')).toBe(false); + + const stream = service.sendMessage( + { prompt: 'Hello again', sessionId: 'session-0' }, + mockAgentProcessConfigWithSmallPool, + ); + const updates: any[] = []; + stream.onData((data) => updates.push(data)); + await flushAsyncWork(); + + expect((service as any).sessions.get('session-0')).toBe(threads[1]); + expect(threads[1].loadSession).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: 'session-0', cwd: mockAgentProcessConfig.cwd }), + ); + expect(threads[1].addUserMessage).toHaveBeenCalledWith('Hello again'); + expect(threads[1].prompt).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: 'session-0', prompt: expect.any(Array) }), + ); + expect(updates).toContainEqual(expect.objectContaining({ type: 'thread_status' })); }); - 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(); + + setTimeout(() => { + thread._fireEvent({ + type: 'session_notification', + notification: { + sessionId: 'session-1', + update: { sessionUpdate: 'available_commands_update', availableCommands: [] }, + }, + }); + }, 10); + + const createResult = await service.createSession(mockAgentProcessConfig); + service.sendMessage({ prompt: 'Hello world', sessionId: createResult.sessionId }, mockAgentProcessConfig); + await flushAsyncWork(); + + expect(thread.addUserMessage).toHaveBeenCalledWith('Hello world'); + expect(thread.prompt).toHaveBeenCalled(); + }); - service.sendMessage({ prompt: 'Hello world', sessionId: 'test-session-123' }); + it('should prepend only the low-priority WebMCP hint for the first built-in MCP 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); + (service as any).builtInMcpSessionIds.add(createResult.sessionId); + + service.sendMessage( + { prompt: 'Explain the current file', sessionId: createResult.sessionId }, + mockAgentProcessConfig, + ); + await flushAsyncWork(); + + const promptBlocks = thread.prompt.mock.calls[0][0].prompt; + expect(promptBlocks).toEqual([ + { + type: 'text', + text: [ + '', + 'Use the opensumi-ide MCP catalog tools to discover and enable IDE capability groups before invoking non-default OpenSumi tools.', + '', + '', + 'Explain the current file', + ].join('\n'), + }, + ]); + expect(promptBlocks[0].text).not.toContain('terminal_create'); + expect(promptBlocks[0].text).not.toContain('Live OpenSumi opensumi-ide MCP registered capability metadata'); + }); - expect(mockCliClientService.prompt).toHaveBeenCalledWith({ - sessionId: 'test-session-123', - prompt: [{ type: 'text', text: 'Hello world' }], - }); + it('should not repeat the WebMCP hint after the first built-in MCP 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); + (service as any).builtInMcpSessionIds.add(createResult.sessionId); + thread.getEntries.mockReturnValue([{ id: 'user-1' }, { id: 'assistant-1' }]); + + service.sendMessage({ prompt: 'Summarize this file', sessionId: createResult.sessionId }, mockAgentProcessConfig); + await flushAsyncWork(); + + expect(thread.prompt).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: [{ type: 'text', text: 'Summarize this file' }], + }), + ); }); - 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...' }); + expect(updates).toContainEqual(expect.objectContaining({ 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.' }); + expect(updates).toContainEqual(expect.objectContaining({ type: 'message', content: 'Here is my answer.' })); }); - it('should handle tool_call notifications', async () => { - const service = createService(); - await service.initializeAgent(mockAgentProcessConfig); + it('should ignore stream updates from a different session', 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: 'stale-session', + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'stale answer' }, + }, }, }); - expect(updates).toContainEqual({ - type: 'tool_call', - content: 'ReadFile', - toolCall: { name: 'ReadFile', input: { path: '/test/file.ts' } }, - }); + expect(updates).not.toContainEqual(expect.objectContaining({ type: 'message', content: 'stale answer' })); + expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('ignoring notification for stale-session')); }); - it('should handle tool_call_update with diff as tool_result', 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_update', - content: [{ type: 'diff', path: 'src/index.ts' }], + thread._fireEvent({ + type: 'session_notification', + notification: { + sessionId: createResult.sessionId, + update: { + sessionUpdate: 'tool_call', + title: 'ReadFile', + rawInput: { path: '/test/file.ts' }, + }, }, }); - expect(updates).toContainEqual({ type: 'tool_result', content: 'Modified src/index.ts' }); + expect(updates).toContainEqual( + expect.objectContaining({ + type: 'tool_call', + content: 'ReadFile', + toolCall: expect.objectContaining({ name: 'ReadFile', input: { path: '/test/file.ts' } }), + }), + ); }); - it('should filter notifications by sessionId', 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: 'other-session', - update: { - sessionUpdate: 'agent_message_chunk', - content: { type: 'text', text: 'Should be ignored' }, + thread._fireEvent({ + type: 'session_notification', + notification: { + sessionId: createResult.sessionId, + update: { + sessionUpdate: 'tool_call_update', + content: [{ type: 'diff', path: 'src/index.ts' }], + }, }, }); - expect(updates).not.toContainEqual({ type: 'message', content: 'Should be ignored' }); + expect(updates).toContainEqual( + expect.objectContaining({ type: 'tool_result', content: 'Modified src/index.ts' }), + ); + }); + + 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(); + }); + }); }); - it('should include images in prompt blocks', async () => { - const service = createService(); - await service.initializeAgent(mockAgentProcessConfig); + 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 imageData = 'data:image/png;base64,iVBORw0KGgo='; - service.sendMessage({ prompt: 'Look at this', sessionId: 'test-session-123', images: [imageData] }); + 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: 'Look at this' }, - { type: 'image', data: 'iVBORw0KGgo=', mimeType: 'image/png' }, - ], + 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)); + + // 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); + + 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' }); }); - }); - describe('cancelRequest()', () => { - it('should call clientService.cancel', async () => { - const service = createService(); - await service.initializeAgent(mockAgentProcessConfig); + it('should include images in prompt', async () => { + const { service, thread } = createServiceWithAutoEvents(); - await service.cancelRequest('test-session-123'); + setTimeout(() => { + thread._fireEvent({ + type: 'session_notification', + notification: { + sessionId: 'session-1', + update: { sessionUpdate: 'available_commands_update', availableCommands: [] }, + }, + }); + }, 10); - expect(mockCliClientService.cancel).toHaveBeenCalledWith({ sessionId: 'test-session-123' }); + const createResult = await service.createSession(mockAgentProcessConfig); + + const imageData = 'data:image/png;base64,iVBORw0KGgo='; + service.sendMessage( + { prompt: 'Look at this', sessionId: createResult.sessionId, images: [imageData] }, + mockAgentProcessConfig, + ); + await flushAsyncWork(); + + expect(thread.prompt).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: expect.arrayContaining([ + { type: 'text', text: expect.stringContaining('Look at this') }, + { type: 'image', data: 'iVBORw0KGgo=', mimeType: 'image/png' }, + ]), + }), + ); }); + }); - it('should return early if process not initialized', async () => { - const service = createService(); - await service.cancelRequest('test-session-123'); + // ----------------------------------------------------------------------- + // cancelRequest + // ----------------------------------------------------------------------- + + 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 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(); + }); + + it('should release a loaded session only after the final retained reference is disposed', 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); + + await Promise.all([ + service.loadSession('shared-session', mockAgentProcessConfig), + service.loadSession('shared-session', mockAgentProcessConfig), + ]); + + mockTerminalHandler.releaseSessionTerminals.mockClear(); + + await service.disposeSession('shared-session'); + + expect(mockTerminalHandler.releaseSessionTerminals).not.toHaveBeenCalled(); + expect((service as any).sessions.get('shared-session')).toBe(thread); + + await service.disposeSession('shared-session'); + + expect(mockTerminalHandler.releaseSessionTerminals).toHaveBeenCalledWith('shared-session'); + expect((service as any).sessions.has('shared-session')).toBe(false); }); }); + // ----------------------------------------------------------------------- + // 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({ + newSession: jest.fn().mockResolvedValue({ sessionId: `session-${i}` }), + 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(mockAgentProcessConfigWithSmallPool); + } 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'); }); }); + // ----------------------------------------------------------------------- + // listSessions + // ----------------------------------------------------------------------- + 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); + it('should return all active sessions', async () => { + const { service } = createServiceWithAutoEvents(); + + for (let i = 0; i < 2; i++) { + const t = createMockThread({ + newSession: jest.fn().mockResolvedValue({ sessionId: `session-${i}` }), + listSessions: jest.fn().mockResolvedValue({ + sessions: [{ sessionId: `session-${i}`, cwd: mockAgentProcessConfig.cwd, title: `Session ${i}` }], + }), + 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(); + }); + + it('should initialize an idle thread to list sessions when no sessions are active', async () => { + const { service, mockFactory, thread } = createService(); + thread.listSessions.mockResolvedValue({ + sessions: [{ sessionId: 'history-session', cwd: mockAgentProcessConfig.cwd, title: 'History Session' }], + nextCursor: 'cursor-1', + }); - const result = await service.listSessions({ cwd: '/test' }); + const result = await service.listSessions({ cwd: mockAgentProcessConfig.cwd }, mockAgentProcessConfig); - expect(result).toEqual(expected); + expect(mockFactory).toHaveBeenCalledTimes(1); + expect(thread.initialize).toHaveBeenCalledWith(expect.objectContaining(mockAgentProcessConfig)); + expect(thread.listSessions).toHaveBeenCalledWith({ cwd: mockAgentProcessConfig.cwd }); + expect(result).toEqual({ + sessions: [{ sessionId: 'history-session', cwd: mockAgentProcessConfig.cwd, title: 'History Session' }], + nextCursor: 'cursor-1', + }); + expect((service as any).sessions.size).toBe(0); + expect((service as any).reservedThreads.has(thread)).toBe(false); }); }); - 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); + // ----------------------------------------------------------------------- + // 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(); + }); - const result = await service.getAvailableModes(); + it('should dispose incompatible idle threads instead of reusing them for different agent process configs', async () => { + const firstThread = createMockThread({ + newSession: jest.fn().mockResolvedValue({ sessionId: 'fixture-a-session' }), + }); + const secondThread = createMockThread({ + newSession: jest.fn().mockResolvedValue({ sessionId: 'fixture-b-session' }), + }); + const mockFactory = jest.fn().mockReturnValueOnce(firstThread).mockReturnValueOnce(secondThread); + const service = setupServiceWithMockFactory(mockFactory); + const configA = { + ...mockAgentProcessConfig, + args: ['mock-acp-agent.mjs', '--fixture=load-failure'], + env: [{ name: 'OPENSUMI_ACP_BDD_FIXTURE', value: 'load-failure' }], + threadPoolSize: 1, + }; + const configB = { + ...mockAgentProcessConfig, + args: ['mock-acp-agent.mjs', '--fixture=history'], + env: [{ name: 'OPENSUMI_ACP_BDD_FIXTURE', value: 'history' }], + threadPoolSize: 1, + }; + + setTimeout(() => { + firstThread._fireEvent({ + type: 'session_notification', + notification: { + sessionId: 'fixture-a-session', + update: { sessionUpdate: 'available_commands_update', availableCommands: [] }, + }, + }); + }, 10); + const result1 = await service.createSession(configA); + + await service.disposeSession(result1.sessionId); + + setTimeout(() => { + secondThread._fireEvent({ + type: 'session_notification', + notification: { + sessionId: 'fixture-b-session', + update: { sessionUpdate: 'available_commands_update', availableCommands: [] }, + }, + }); + }, 10); + const result2 = await service.createSession(configB); + + expect(result2.sessionId).toBe('fixture-b-session'); + expect(mockFactory).toHaveBeenCalledTimes(2); + expect(firstThread.dispose).toHaveBeenCalledTimes(1); + expect(firstThread.reset).not.toHaveBeenCalled(); + expect(secondThread.initialize).toHaveBeenCalledWith( + expect.objectContaining({ args: configB.args, env: configB.env }), + ); + }); - expect(result).toEqual(expected); + it('should track maxPoolSize correctly', async () => { + const { service } = createService(); + expect((service as any).maxPoolSize).toBe(DEFAULT_ACP_THREAD_POOL_SIZE); + }); + + it('should apply configured maxPoolSize from agent process config', async () => { + const { service } = createService(); + (service as any).syncMaxPoolSize({ ...mockAgentProcessConfig, threadPoolSize: 4 }); + expect((service as any).maxPoolSize).toBe(4); }); }); + // ----------------------------------------------------------------------- + // 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-browser-rpc-bridge.test.ts b/packages/ai-native/__test__/node/acp-browser-rpc-bridge.test.ts new file mode 100644 index 0000000000..b2b4bccc6c --- /dev/null +++ b/packages/ai-native/__test__/node/acp-browser-rpc-bridge.test.ts @@ -0,0 +1,89 @@ +jest.mock('@opensumi/di', () => { + const actual = jest.requireActual('@opensumi/di'); + const noopDecorator = () => () => {}; + return { + ...actual, + Injectable: () => (cls: any) => cls, + Autowired: noopDecorator, + }; +}); + +import { + AcpPermissionRpcBridgeService, + AcpThreadStatusRpcBridgeService, + AcpWebMcpRpcBridgeService, +} from '../../src/node/acp/acp-browser-rpc-bridge.service'; +import { AcpBrowserRpcRegistry } from '../../src/node/acp/acp-browser-rpc-registry'; + +function wireBridge(bridge: T, registry: AcpBrowserRpcRegistry, clientId: string): T { + (bridge as any).browserRpcRegistry = registry; + (bridge as any).clientId = clientId; + return bridge; +} + +describe('ACP browser RPC bridge services', () => { + it('should register and unregister permission RPC clients', () => { + const registry = new AcpBrowserRpcRegistry(); + const bridge = wireBridge(new AcpPermissionRpcBridgeService(), registry, 'client-1'); + const client = { + $showPermissionDialog: jest.fn(), + $cancelRequest: jest.fn(), + }; + + bridge.rpcClient = [client]; + expect(registry.getPermissionClient('client-1')).toBe(client); + expect(registry.getPermissionClient()).toBe(client); + + bridge.dispose(); + expect(registry.getPermissionClient('client-1')).toBeUndefined(); + }); + + it('should register and unregister thread status RPC clients', () => { + const registry = new AcpBrowserRpcRegistry(); + const bridge = wireBridge(new AcpThreadStatusRpcBridgeService(), registry, 'client-1'); + const client = { + $onThreadStatusChange: jest.fn(), + }; + + bridge.rpcClient = [client]; + expect(registry.getThreadStatusClient('client-1')).toBe(client); + + bridge.dispose(); + expect(registry.getThreadStatusClient('client-1')).toBeUndefined(); + }); + + it('should register and unregister WebMCP RPC clients', () => { + const registry = new AcpBrowserRpcRegistry(); + const bridge = wireBridge(new AcpWebMcpRpcBridgeService(), registry, 'client-1'); + const client = { + $getGroupDefinitions: jest.fn(), + $executeTool: jest.fn(), + }; + + bridge.rpcClient = [client]; + expect(registry.getWebMcpClient('client-1')).toBe(client); + + bridge.dispose(); + expect(registry.getWebMcpClient('client-1')).toBeUndefined(); + }); + + it('should keep the newer client when an older bridge is disposed later', () => { + const registry = new AcpBrowserRpcRegistry(); + const oldBridge = wireBridge(new AcpPermissionRpcBridgeService(), registry, 'client-1'); + const newBridge = wireBridge(new AcpPermissionRpcBridgeService(), registry, 'client-1'); + const oldClient = { + $showPermissionDialog: jest.fn(), + $cancelRequest: jest.fn(), + }; + const newClient = { + $showPermissionDialog: jest.fn(), + $cancelRequest: jest.fn(), + }; + + oldBridge.rpcClient = [oldClient]; + newBridge.rpcClient = [newClient]; + oldBridge.dispose(); + + expect(registry.getPermissionClient('client-1')).toBe(newClient); + }); +}); 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..820c928a76 100644 --- a/packages/ai-native/__test__/node/acp-cli-back.test.ts +++ b/packages/ai-native/__test__/node/acp-cli-back.test.ts @@ -2,8 +2,10 @@ import { AgentProcessConfig, CancellationToken, Emitter } from '@opensumi/ide-co import { ChatReadableStream, INodeLogger } from '@opensumi/ide-core-node'; import { SumiReadableStream } from '@opensumi/ide-utils/lib/stream'; +import { toAgentUpdate } from '../../src/node/acp/acp-agent-update-adapter'; 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 +22,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,19 +38,25 @@ describe('AcpCliBackService', () => { beforeEach(() => { jest.clearAllMocks(); + const mockOnThreadStatusChange = new Emitter<{ sessionId: string; status: string }>(); + mockAgentService = { createSession: jest.fn(), initializeAgent: jest.fn(), sendMessage: jest.fn(), cancelRequest: jest.fn(), disposeSession: jest.fn(), + closeSession: jest.fn(), dispose: jest.fn(), getSessionInfo: jest.fn(), loadSession: jest.fn(), + loadSessionOrNew: jest.fn(), listSessions: jest.fn(), setSessionMode: jest.fn(), stopAgent: jest.fn(), getAvailableModes: jest.fn(), + getOpenSumiMcpServerConnection: jest.fn(), + onThreadStatusChange: mockOnThreadStatusChange.event, } as unknown as jest.Mocked; mockLogger = { @@ -68,8 +77,13 @@ describe('AcpCliBackService', () => { service = new AcpCliBackService(); Object.defineProperty(service, 'agentService', { value: mockAgentService, writable: true }); + Object.defineProperty(service, 'clientId', { value: undefined, writable: true, configurable: 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()', () => { @@ -79,11 +93,114 @@ describe('AcpCliBackService', () => { }); }); + describe('getOpenSumiMcpServerConnection()', () => { + it('should proxy the built-in MCP connection descriptor from AcpAgentService', async () => { + const connection = { + name: 'opensumi-ide', + type: 'http', + transport: 'streamable-http', + url: 'http://127.0.0.1:12345/mcp/token', + redactedUrl: 'http://127.0.0.1:12345/mcp/', + headers: [], + } as any; + mockAgentService.getOpenSumiMcpServerConnection.mockResolvedValue(connection); + + await expect(service.getOpenSumiMcpServerConnection()).resolves.toBe(connection); + expect(mockAgentService.getOpenSumiMcpServerConnection).toHaveBeenCalledWith(undefined); + }); + + it('should pass the browser client id to AcpAgentService', async () => { + const connection = { + name: 'opensumi-ide', + type: 'http', + transport: 'streamable-http', + url: 'http://127.0.0.1:12345/mcp/token?clientId=client-full', + redactedUrl: 'http://127.0.0.1:12345/mcp/?clientId=%3Credacted%3E', + headers: [], + } as any; + Object.defineProperty(service, 'clientId', { value: 'client-full', writable: true, configurable: true }); + mockAgentService.getOpenSumiMcpServerConnection.mockResolvedValue(connection); + + await expect(service.getOpenSumiMcpServerConnection()).resolves.toBe(connection); + expect(mockAgentService.getOpenSumiMcpServerConnection).toHaveBeenCalledWith('client-full'); + }); + }); + describe('request()', () => { - it('should return error code -1 indicating not supported', async () => { + it('should collect OpenAI-compatible stream content when agent config is not provided', async () => { + (mockOpenAIModel.request as jest.Mock).mockImplementation(async (_input, stream: ChatReadableStream) => { + stream.emitData({ kind: 'content', content: 'hello' }); + stream.emitData({ kind: 'content', content: ' world' }); + stream.end(); + }); + const result = await service.request('hello', {}); - expect(result.errorCode).toBe(-1); - expect(result.errorMsg).toContain('not supported'); + + expect(result).toEqual({ + errorCode: 0, + data: 'hello world', + }); + expect(mockOpenAIModel.request).toHaveBeenCalled(); + }); + + it('should create an ephemeral ACP session, collect message updates, and force dispose it', async () => { + mockAgentService.createSession.mockResolvedValue({ sessionId: 'summary-session', availableCommands: [] }); + const agentStream = new SumiReadableStream(); + mockAgentService.sendMessage.mockReturnValue(agentStream); + + const resultPromise = service.request('summarize this', { + agentSessionConfig: mockAgentSessionConfig, + noTool: true, + type: 'acp_chat_relay_summary', + }); + + agentStream.emitData({ type: 'thought', content: 'thinking' }); + agentStream.emitData({ type: 'message', content: 'summary ' }); + agentStream.emitData({ type: 'message', content: 'text' }); + agentStream.emitData({ type: 'done', content: '' }); + + await expect(resultPromise).resolves.toEqual({ + errorCode: 0, + data: 'summary text', + }); + expect(mockAgentService.createSession).toHaveBeenCalledWith({ + ...mockAgentSessionConfig, + mcpServers: [], + }); + expect(mockAgentService.sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: 'summary-session', + prompt: expect.stringContaining('summarize this'), + }), + expect.any(Object), + ); + expect(mockAgentService.closeSession).toHaveBeenCalledWith({ sessionId: 'summary-session' }); + expect(mockAgentService.disposeSession).toHaveBeenCalledWith('summary-session', true); + }); + + it('should strip MCP servers for no-tool ACP requests', async () => { + mockAgentService.createSession.mockResolvedValue({ sessionId: 'summary-session', availableCommands: [] }); + const agentStream = new SumiReadableStream(); + mockAgentService.sendMessage.mockReturnValue(agentStream); + + const resultPromise = service.request('summarize this', { + agentSessionConfig: { + ...mockAgentSessionConfig, + mcpServers: [{ name: 'test', command: 'node', args: ['server.js'], env: [] }], + }, + noTool: true, + }); + + agentStream.emitData({ type: 'message', content: 'summary' }); + agentStream.emitData({ type: 'done', content: '' }); + + await resultPromise; + + expect(mockAgentService.createSession).toHaveBeenCalledWith( + expect.objectContaining({ + mcpServers: [], + }), + ); }); }); @@ -97,25 +214,22 @@ 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: [] }); + describe('loadSessionOrNew()', () => { + it('should return the session id resolved by agentService', async () => { + mockAgentService.loadSessionOrNew.mockResolvedValue({ + sessionId: 'actual-session-id', + processId: 'proc-1', + modes: [], + status: 'ready', + historyUpdates: [], + }); - await service.createSession(mockAgentSessionConfig); + const result = await service.loadSessionOrNew(mockAgentSessionConfig, 'requested-session-id'); - expect(mockAgentService.initializeAgent).toHaveBeenCalledWith(mockAgentSessionConfig); + expect(result.sessionId).toBe('actual-session-id'); + expect(mockAgentService.loadSessionOrNew).toHaveBeenCalledWith('requested-session-id', mockAgentSessionConfig); }); }); @@ -135,20 +249,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 +280,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 +295,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 +332,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); @@ -222,8 +352,62 @@ describe('AcpCliBackService', () => { }); describe('convertAgentUpdateToChatProgress()', () => { + it('should convert native current_mode_update to a session_state update', () => { + expect( + toAgentUpdate({ + sessionId: 'sess-1', + update: { + sessionUpdate: 'current_mode_update', + currentModeId: 'code', + }, + } as any), + ).toEqual({ + type: 'session_state', + content: '', + sessionId: 'sess-1', + currentModeId: 'code', + }); + }); + + it('should convert native config_option_update to a session_state update', () => { + const configOptions = [{ id: 'permission', name: 'Permission', currentValue: 'default' }]; + + expect( + toAgentUpdate({ + sessionId: 'sess-1', + update: { + sessionUpdate: 'config_option_update', + configOptions, + }, + } as any), + ).toEqual({ + type: 'session_state', + content: '', + sessionId: 'sess-1', + configOptions, + }); + }); + + it('should convert native top-level plan entries to plan update content', () => { + expect( + toAgentUpdate({ + sessionId: 'sess-1', + update: { + sessionUpdate: 'plan', + entries: [ + { content: 'BDD plan: prepare deterministic stream', status: 'completed', priority: 'high' }, + { content: 'BDD plan: emit tool update', status: 'in_progress', priority: 'medium' }, + ], + }, + } as any), + ).toEqual({ + type: 'plan', + content: '- [x] BDD plan: prepare deterministic stream\n- [ ] BDD plan: emit tool update\n\n', + }); + }); + 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 +422,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); @@ -252,8 +436,39 @@ describe('AcpCliBackService', () => { expect(receivedData).toEqual([{ kind: 'content', content: 'Answer text' }]); }); + it('should convert "session_state" update to sessionState progress', 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 receivedData: any[] = []; + output.onData((data) => receivedData.push(data)); + + const configOptions = [{ id: 'permission', name: 'Permission', currentValue: 'default' }]; + agentStream.emitData({ + type: 'session_state', + content: '', + sessionId: 'sess-1', + currentModeId: 'code', + currentModelId: 'qwen3.6-plus', + configOptions, + }); + agentStream.emitData({ type: 'done', content: '' }); + + expect(receivedData).toEqual([ + { + kind: 'sessionState', + sessionId: 'sess-1', + currentModeId: 'code', + currentModelId: 'qwen3.6-plus', + configOptions, + }, + ]); + }); + 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 +482,37 @@ 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); + + const output = await service.requestStream('prompt', { agentSessionConfig: mockAgentSessionConfig }); + const receivedData: any[] = []; + output.onData((data) => receivedData.push(data)); + + agentStream.emitData({ + type: 'tool_call', + content: 'read_file', + toolCall: { toolCallId: 'tc-1', name: 'read_file', input: {} }, + }); + agentStream.emitData({ type: 'done', content: '' }); + + expect(receivedData).toEqual([ + { + kind: 'toolCall', + content: { + id: 'tc-1', + type: 'function', + function: { name: 'read_file', arguments: '{}' }, + state: 'complete', + }, + }, + ]); + }); + + it('should update cached tool_call arguments from "tool_call_args" updates', async () => { + mockAgentService.createSession.mockResolvedValue({ sessionId: 'new-session', availableCommands: [] }); const agentStream = new SumiReadableStream(); mockAgentService.sendMessage.mockReturnValue(agentStream); @@ -276,10 +520,53 @@ 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: 'tool_call_args', + content: '', + toolCall: { toolCallId: 'tc-1', name: 'read_file', input: { path: '/test/file.ts' } }, + }); + agentStream.emitData({ + type: 'tool_result', + content: 'file contents', + toolCall: { toolCallId: 'tc-1', name: 'read_file', status: 'completed' }, + }); agentStream.emitData({ type: 'done', content: '' }); - expect(receivedData).toEqual([]); + expect(receivedData).toEqual([ + { + kind: 'toolCall', + content: { + id: 'tc-1', + type: 'function', + function: { name: 'read_file', arguments: '{}' }, + state: 'complete', + }, + }, + { + kind: 'toolCall', + content: { + id: 'tc-1', + type: 'function', + function: { name: 'read_file', arguments: '{"path":"/test/file.ts"}' }, + state: 'complete', + }, + }, + { + kind: 'toolCall', + content: { + id: 'tc-1', + type: 'function', + function: { name: 'read_file', arguments: '{"path":"/test/file.ts"}' }, + result: 'file contents', + state: 'result', + }, + }, + ]); }); }); @@ -319,6 +606,42 @@ describe('AcpCliBackService', () => { ]); }); + it('should ignore non-message native history updates when restoring messages', async () => { + mockAgentService.loadSession.mockResolvedValue({ + sessionId: 'sess-1', + processId: 'proc-1', + modes: [], + status: 'ready', + historyUpdates: [ + ...mockSessionNotifications, + { + sessionId: 'sess-1', + update: { + sessionUpdate: 'tool_call_update', + toolCallId: 'tc-1', + status: 'completed', + content: [{ type: 'diff', path: 'src/index.ts' }], + }, + }, + { + sessionId: 'sess-1', + update: { + sessionUpdate: 'usage_update', + used: 10, + size: 100, + }, + }, + ], + }); + + const result = await service.loadAgentSession(mockAgentSessionConfig, 'sess-1'); + + expect(result.messages).toEqual([ + { role: 'user', content: 'Hello agent' }, + { role: 'assistant', content: 'Hi there!' }, + ]); + }); + it('should handle load session error', async () => { mockAgentService.loadSession.mockRejectedValue(new Error('Session not found')); @@ -335,6 +658,19 @@ describe('AcpCliBackService', () => { 'Failed to load session sess-1: string error', ); }); + + it('should stringify object-shaped load session errors', async () => { + mockAgentService.loadSession.mockRejectedValue({ + code: -32603, + error: { + message: 'Session load failed', + }, + }); + + await expect(service.loadAgentSession(mockAgentSessionConfig, 'sess-1')).rejects.toThrow( + 'Failed to load session sess-1: Session load failed', + ); + }); }); describe('disposeSession()', () => { @@ -382,39 +718,29 @@ 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, - }); + expect(mockAgentService.listSessions).toHaveBeenCalledWith( + { + cwd: mockAgentSessionConfig.cwd, + }, + mockAgentSessionConfig, + ); 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 +790,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 +813,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 +829,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 +848,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 +861,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 +895,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 +923,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..b4b04a38a3 100644 --- a/packages/ai-native/__test__/node/acp-permission-caller.test.ts +++ b/packages/ai-native/__test__/node/acp-permission-caller.test.ts @@ -10,70 +10,26 @@ jest.mock('@opensumi/di', () => { }; }); +import { AcpBrowserRpcRegistry } from '../../src/node/acp/acp-browser-rpc-registry'; 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 +42,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 +62,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 +80,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 +95,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 +123,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 +158,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 +181,55 @@ 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({ + 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 fall back to registered browser RPC client when instance client is unavailable', async () => { + Object.defineProperty(service, 'rpcClient', { value: undefined, writable: true }); + const registry = new AcpBrowserRpcRegistry(); + registry.registerPermissionClient('client-1', mockRpcClient as any); + (service as any).browserRpcRegistry = registry; + + mockRpcClient.$showPermissionDialog.mockResolvedValue({ type: 'allow', optionId: 'allow_once' }); + + await 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 }], - }), - ).rejects.toThrow('[ACP Permission Caller] No active RPC client available'); + options: [{ optionId: 'allow_once', name: 'Allow Once', kind: 'allow_once' as const }], + }, + 'sess-1', + ); + + expect(mockRpcClient.$showPermissionDialog).toHaveBeenCalled(); }); - 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 +242,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 +322,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 }); - - 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'); + Object.defineProperty(service, 'rpcClient', { value: undefined, writable: true }); - 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..70a4a72f40 --- /dev/null +++ b/packages/ai-native/__test__/node/acp-thread-status-caller.test.ts @@ -0,0 +1,68 @@ +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 { AcpBrowserRpcRegistry } from '../../src/node/acp/acp-browser-rpc-registry'; +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(); + service = new AcpThreadStatusCallerService(); + Object.defineProperty(service, 'rpcClient', { value: [mockRpcClient], writable: true }); + }); + + 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 registered browser RPC client when instance client is unavailable', () => { + Object.defineProperty(service, 'rpcClient', { value: undefined, writable: true }); + const registry = new AcpBrowserRpcRegistry(); + const registeredClient = { $onThreadStatusChange: jest.fn().mockResolvedValue(undefined) }; + registry.registerThreadStatusClient('client-1', registeredClient as any); + (service as any).browserRpcRegistry = registry; + + service.notifyThreadStatusChange('session-1', 'working'); + + expect(registeredClient.$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(); + }); + }); +}); diff --git a/packages/ai-native/__test__/node/acp/acp-debug-log.test.ts b/packages/ai-native/__test__/node/acp/acp-debug-log.test.ts new file mode 100644 index 0000000000..4b621ef8f7 --- /dev/null +++ b/packages/ai-native/__test__/node/acp/acp-debug-log.test.ts @@ -0,0 +1,52 @@ +import { AcpDebugLogStore } from '../../../src/node/acp/acp-debug-log'; + +describe('AcpDebugLogStore', () => { + it('records outgoing, incoming, and stderr lines with parsed payloads', () => { + const store = new AcpDebugLogStore(); + const outgoing = store.createLineRecorder({ direction: 'outgoing', agentId: 'agent', threadId: 'thread' }); + const incoming = store.createLineRecorder({ direction: 'incoming', agentId: 'agent', threadId: 'thread' }); + const stderr = store.createLineRecorder({ direction: 'stderr', agentId: 'agent', threadId: 'thread' }); + + outgoing(Buffer.from('{"method":"initialize"}\n')); + incoming(Buffer.from('{"result":{"ok":true}}\n')); + stderr(Buffer.from('warning\n')); + + const entries = store.getEntries(); + expect(entries.map((entry) => entry.direction)).toEqual(['outgoing', 'incoming', 'stderr']); + expect(entries[0].payload).toEqual({ method: 'initialize' }); + expect(entries[1].payload).toEqual({ result: { ok: true } }); + expect(entries[2].raw).toBe('warning'); + }); + + it('keeps the latest 2000 entries', () => { + const store = new AcpDebugLogStore(); + for (let i = 0; i < 2005; i++) { + store.record({ + direction: 'system', + agentId: 'agent', + threadId: 'thread', + raw: `line-${i}`, + }); + } + + const entries = store.getEntries(); + expect(entries).toHaveLength(2000); + expect(entries[0].raw).toBe('line-5'); + expect(entries[1999].raw).toBe('line-2004'); + }); + + it('clears entries and can backfill session ids for existing thread entries', () => { + const store = new AcpDebugLogStore(); + store.record({ direction: 'system', agentId: 'agent', threadId: 'thread', raw: 'before session' }); + store.setThreadSessionId('thread', 'sess-1'); + store.record({ direction: 'system', agentId: 'agent', threadId: 'thread', raw: 'after session' }); + + expect(store.getEntries().map((entry) => entry.sessionId)).toEqual(['sess-1', 'sess-1']); + + store.clear(); + expect(store.getEntries()).toEqual([]); + + store.record({ direction: 'system', agentId: 'agent', threadId: 'thread', raw: 'after clear' }); + expect(store.getEntries()[0].sessionId).toBe('sess-1'); + }); +}); 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..d5ed0564c9 --- /dev/null +++ b/packages/ai-native/__test__/node/acp/acp-thread.test.ts @@ -0,0 +1,1431 @@ +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 recover to awaiting_prompt when prompt fails while working', async () => { + (thread as any)._connected = true; + (thread as any)._connection = { + prompt: jest.fn().mockRejectedValue(new Error('BDD send failure')), + }; + (thread as any)._initialized = true; + + const events: any[] = []; + thread.onEvent((event) => events.push(event)); + + await expect(thread.prompt({} as any)).rejects.toThrow('BDD send failure'); + + expect(thread.status).toBe('awaiting_prompt'); + expect(events.filter((event) => event.type === 'status_changed').map((event) => event.status)).toEqual([ + 'working', + 'awaiting_prompt', + ]); + }); + + it('should reject a pending prompt when the connection closes', async () => { + let resolveClosed: (() => void) | undefined; + (thread as any)._connected = true; + (thread as any)._connection = { + prompt: jest.fn().mockImplementation(() => new Promise(() => undefined)), + closed: new Promise((resolve) => { + resolveClosed = resolve; + }), + }; + (thread as any)._initialized = true; + + const promptPromise = thread.prompt({} as any); + await new Promise((r) => setTimeout(r, 10)); + + expect(thread.status).toBe('working'); + + resolveClosed!(); + + await expect(promptPromise).rejects.toThrow('ACP agent connection closed while waiting for prompt response.'); + expect(thread.status).toBe('disconnected'); + }); + + 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', + rawInput: { 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.toolCall.rawInput).toEqual({ path: 'test.txt' }); + 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 update tool call rawInput 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', + rawInput: { path: 'updated.txt' }, + }, + } as any); + + const data = getToolCallData(thread.entries[0])!; + expect(data.toolCall.rawInput).toEqual({ path: 'updated.txt' }); + }); + + 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 ignore notifications from a different bound session', () => { + (thread as any)._sessionId = 'current-session'; + + thread.handleNotification({ + sessionId: 'stale-session', + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'stale answer' }, + }, + } as any); + + expect(thread.entries).toEqual([]); + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('Ignoring session notification for stale-session'), + ); + }); + + 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 create plan entry from top-level ACP plan entries', () => { + thread.handleNotification({ + sessionId: 's1', + update: { + sessionUpdate: 'plan', + entries: [ + { content: 'BDD plan: prepare deterministic stream', status: 'completed', priority: 'high' }, + { content: 'BDD plan: emit tool update', status: 'in_progress', priority: 'medium' }, + ], + }, + } as any); + + expect(thread.entries).toHaveLength(1); + expect(thread.entries[0].type).toBe('plan'); + expect((thread.entries[0] as any).data.entries).toHaveLength(2); + expect((thread.entries[0] as any).data.entries[0].content).toBe('BDD plan: prepare deterministic stream'); + }); + + 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'); + }); + }); + + // =================================================================== + // Native SessionNotification history and session state + // =================================================================== + describe('native session state', () => { + it('should record native notifications received from the ACP client', async () => { + (thread as any)._sessionId = 's1'; + const client = (thread as any).createClientImpl(); + + await client.sessionUpdate({ + sessionId: 's1', + update: { + sessionUpdate: 'tool_call_update', + toolCallId: 'tc-1', + status: 'completed', + content: [{ type: 'diff', path: 'src/index.ts' }], + rawOutput: { changedFiles: ['src/index.ts'] }, + }, + }); + + expect(thread.getSessionNotifications()).toEqual([ + expect.objectContaining({ + sessionId: 's1', + update: expect.objectContaining({ + sessionUpdate: 'tool_call_update', + rawOutput: { changedFiles: ['src/index.ts'] }, + content: [{ type: 'diff', path: 'src/index.ts' }], + }), + }), + ]); + }); + + it('should not record stale session notifications', async () => { + (thread as any)._sessionId = 'current-session'; + const client = (thread as any).createClientImpl(); + + await client.sessionUpdate({ + sessionId: 'stale-session', + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'stale answer' }, + }, + }); + + expect(thread.getSessionNotifications()).toEqual([]); + expect(thread.entries).toEqual([]); + }); + + it('should return a cloned notification history', async () => { + (thread as any)._sessionId = 's1'; + const client = (thread as any).createClientImpl(); + + await client.sessionUpdate({ + sessionId: 's1', + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'answer' }, + }, + }); + + const notifications = thread.getSessionNotifications() as any[]; + notifications[0].update.content.text = 'mutated'; + + expect((thread.getSessionNotifications()[0].update as any).content.text).toBe('answer'); + }); + + it('should apply initial modes, config options, and models from session responses', async () => { + const connection = { + loadSession: jest.fn().mockResolvedValue({ + modes: { + availableModes: [{ id: 'code', name: 'Code' }], + currentModeId: 'code', + }, + configOptions: [{ id: 'permission', name: 'Permission' }], + models: { + availableModels: [{ modelId: 'sonnet', name: 'Sonnet' }], + currentModelId: 'sonnet', + }, + }), + }; + (thread as any)._initialized = true; + (thread as any)._connection = connection; + + await thread.loadSession({ sessionId: 's1' } as any); + + expect(thread.getSessionState()).toEqual( + expect.objectContaining({ + currentModeId: 'code', + modes: [{ id: 'code', name: 'Code' }], + currentModelId: 'sonnet', + models: [{ modelId: 'sonnet', name: 'Sonnet' }], + configOptions: [{ id: 'permission', name: 'Permission' }], + }), + ); + }); + + it('should update ACP-derived session state from notifications', () => { + thread.handleNotification({ + sessionId: 's1', + update: { + sessionUpdate: 'current_mode_update', + currentModeId: 'code', + }, + } as any); + thread.handleNotification({ + sessionId: 's1', + update: { + sessionUpdate: 'config_option_update', + configOptions: [{ id: 'permission', name: 'Permission' }], + }, + } as any); + thread.handleNotification({ + sessionId: 's1', + update: { + sessionUpdate: 'usage_update', + used: 42, + size: 100, + }, + } as any); + thread.handleNotification({ + sessionId: 's1', + update: { + sessionUpdate: 'session_info_update', + title: 'Loaded session', + }, + } as any); + thread.handleNotification({ + sessionId: 's1', + update: { + sessionUpdate: 'session_info_update', + updatedAt: '2026-05-29T00:00:00.000Z', + }, + } as any); + + expect(thread.getSessionState()).toEqual( + expect.objectContaining({ + currentModeId: 'code', + configOptions: [{ id: 'permission', name: 'Permission' }], + usage: { used: 42, size: 100 }, + sessionInfo: { + title: 'Loaded session', + updatedAt: '2026-05-29T00:00:00.000Z', + }, + }), + ); + }); + + it('should clear native history and session state on reset', async () => { + (thread as any)._sessionId = 's1'; + const client = (thread as any).createClientImpl(); + + await client.sessionUpdate({ + sessionId: 's1', + update: { + sessionUpdate: 'current_mode_update', + currentModeId: 'code', + }, + }); + + thread.reset(); + + expect(thread.getSessionNotifications()).toEqual([]); + expect(thread.getSessionState()).toEqual( + expect.objectContaining({ + notifications: [], + entries: [], + currentModeId: undefined, + modes: undefined, + }), + ); + }); + }); + + // =================================================================== + // 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(); + }); + }); + + describe('permission request handling', () => { + it('should clear the pending request and update the raw tool call id on approval', async () => { + (thread as any)._sessionId = 's1'; + thread.handleNotification({ + sessionId: 's1', + update: { + sessionUpdate: 'tool_call', + toolCallId: 'tc-1', + toolName: 'Write', + }, + } as any); + + const response = await (thread as any).handlePermissionRequest({ + sessionId: 's1', + toolCall: { + toolCallId: 'tc-1', + }, + }); + + expect(response).toEqual({ outcome: { outcome: 'allowed' } }); + expect((thread as any)._pendingPermissionRequests.size).toBe(0); + expect(getToolCallData(thread.entries[0])!.status).toBe('completed'); + }); + }); + + // =================================================================== + // 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/opensumi-mcp-http-server.test.ts b/packages/ai-native/__test__/node/opensumi-mcp-http-server.test.ts new file mode 100644 index 0000000000..e291f26dcc --- /dev/null +++ b/packages/ai-native/__test__/node/opensumi-mcp-http-server.test.ts @@ -0,0 +1,671 @@ +jest.mock('@opensumi/di', () => { + const actual = jest.requireActual('@opensumi/di'); + const noopDecorator = () => () => {}; + return { + ...actual, + Injectable: () => (cls: any) => cls, + Autowired: noopDecorator, + }; +}); + +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; + +import { OpenSumiMcpHttpServer } from '../../src/node/acp/opensumi-mcp-http-server'; + +import type { ILogger } from '@opensumi/ide-core-common'; +import type { WebMcpGroupDef, WebMcpToolResult } from '@opensumi/ide-core-common/lib/types/ai-native/acp-types'; + +(global as any).fetch = require('node-fetch'); + +const LOWER_SNAKE_TOOL_NAME = /^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/; +const FILE_MUTATION_TOOL_NAMES = ['file_create', 'file_write', 'file_copy', 'file_move', 'file_delete']; +const EDITOR_TERMINAL_MUTATION_TOOL_NAMES = ['editor_format', 'editor_save', 'terminal_dispose']; + +const testGroupDefs = [ + { + name: 'file', + description: 'File operations', + defaultLoaded: true, + profile: 'default', + tools: [ + { + name: 'file_read', + description: 'Read file', + riskLevel: 'read', + inputSchema: { + type: 'object', + properties: { + path: { + type: 'string', + }, + }, + required: ['path'], + }, + }, + { + name: 'file_delete', + description: 'Delete file', + riskLevel: 'destructive', + exposedByDefault: false, + inputSchema: { + type: 'object', + properties: { + path: { + type: 'string', + }, + }, + required: ['path'], + }, + }, + ], + }, + { + name: 'search', + description: 'Search operations', + defaultLoaded: true, + profile: 'default', + tools: [ + { + name: 'search_text', + description: 'Search text', + riskLevel: 'read', + profiles: ['interactive', 'full'], + inputSchema: { + type: 'object', + properties: { + query: { + type: 'string', + }, + }, + required: ['query'], + }, + }, + ], + }, + { + name: 'terminal', + description: 'Terminal operations', + defaultLoaded: true, + profile: 'default', + tools: [ + { + name: 'terminal_create', + description: 'Create terminal', + riskLevel: 'shell', + profiles: ['interactive', 'full'], + inputSchema: { + type: 'object', + properties: { + cwd: { + type: 'string', + }, + }, + }, + }, + { + name: 'terminal_run_command', + description: 'Run command', + riskLevel: 'shell', + profiles: ['interactive', 'full'], + inputSchema: { + type: 'object', + properties: { + id: { + type: 'string', + }, + command: { + type: 'string', + }, + }, + required: ['id', 'command'], + }, + }, + ], + }, + { + name: 'acp_chat', + description: 'ACP chat operations', + defaultLoaded: true, + profile: 'default', + tools: [ + { + name: 'acp_chat_get_session_state', + description: 'Get ACP chat session state', + riskLevel: 'read', + inputSchema: { + type: 'object', + properties: {}, + }, + }, + { + name: 'acp_chat_set_session_mode', + description: 'Set ACP session mode', + riskLevel: 'write', + profiles: ['full'], + inputSchema: { + type: 'object', + properties: { + modeId: { + type: 'string', + }, + }, + required: ['modeId'], + }, + }, + ], + }, +] as WebMcpGroupDef[]; + +const mockLogger: ILogger = { + 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(), +}; + +function createServer(caller: { + getGroupDefinitions: jest.Mock, [Record?, string?]>; + executeTool: jest.Mock, [string, string, Record, string?]>; +}): OpenSumiMcpHttpServer { + const server = new OpenSumiMcpHttpServer(); + (server as any).caller = caller; + (server as any).logger = mockLogger; + return server; +} + +function createFileMutationGroupDefs(profile: 'default' | 'interactive' | 'full'): WebMcpGroupDef[] { + return [ + { + name: 'file', + description: 'File operations', + defaultLoaded: true, + profile, + tools: [ + { + name: 'file_read', + description: 'Read file', + riskLevel: 'read', + inputSchema: { + type: 'object', + properties: { + path: { + type: 'string', + }, + }, + required: ['path'], + }, + }, + ...FILE_MUTATION_TOOL_NAMES.map((name) => ({ + name, + description: `${name} test tool`, + riskLevel: name === 'file_delete' ? 'destructive' : 'write', + profiles: ['full'], + inputSchema: { + type: 'object', + properties: { + path: { + type: 'string', + }, + }, + }, + })), + ], + }, + ] as WebMcpGroupDef[]; +} + +function createEditorTerminalMutationGroupDefs(profile: 'default' | 'interactive' | 'full'): WebMcpGroupDef[] { + return [ + { + name: 'editor', + description: 'Editor operations', + defaultLoaded: true, + profile, + tools: [ + { + name: 'editor_format', + description: 'Format editor buffer', + riskLevel: 'write', + profiles: ['full'], + inputSchema: { + type: 'object', + properties: { + path: { + type: 'string', + }, + }, + required: ['path'], + }, + }, + { + name: 'editor_save', + description: 'Save editor buffer', + riskLevel: 'write', + profiles: ['full'], + inputSchema: { + type: 'object', + properties: { + path: { + type: 'string', + }, + }, + required: ['path'], + }, + }, + ], + }, + { + name: 'terminal', + description: 'Terminal operations', + defaultLoaded: true, + profile, + tools: [ + { + name: 'terminal_dispose', + description: 'Dispose terminal', + riskLevel: 'destructive', + profiles: ['full'], + inputSchema: { + type: 'object', + properties: { + id: { + type: 'string', + }, + }, + required: ['id'], + }, + }, + ], + }, + ] as WebMcpGroupDef[]; +} + +async function listMcpToolNames(groupDefs: WebMcpGroupDef[]): Promise { + const caller = { + getGroupDefinitions: jest.fn().mockResolvedValue(groupDefs), + executeTool: jest.fn().mockResolvedValue({ + success: true, + }), + }; + const server = createServer(caller); + await server.start(); + const client = new Client( + { + name: 'test-client', + version: '1.0.0', + }, + { + capabilities: {}, + }, + ); + const transport = new StreamableHTTPClientTransport(new URL(server.getUrl())); + + try { + await client.connect(transport); + const tools = await client.listTools(); + return tools.tools.map((tool) => tool.name).sort(); + } finally { + await client.close(); + await server.dispose(); + } +} + +describe('OpenSumiMcpHttpServer', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should expose WebMCP tools through MCP listTools and callTool', async () => { + const caller = { + getGroupDefinitions: jest.fn().mockResolvedValue(testGroupDefs), + executeTool: jest.fn().mockResolvedValue({ + success: true, + result: { path: 'README.md', content: 'hello' }, + }), + }; + const server = createServer(caller); + await server.start(); + const fullUrl = server.getUrl(); + expect(server.getConnectionInfo()).toEqual({ + name: 'opensumi-ide', + type: 'http', + transport: 'streamable-http', + url: fullUrl, + redactedUrl: expect.stringContaining('/mcp/'), + headers: [], + }); + const token = fullUrl.slice(fullUrl.lastIndexOf('/') + 1); + const listeningLog = (mockLogger.log as jest.Mock).mock.calls.find(([message]) => + String(message).includes('[OpenSumiMcpHttpServer] Listening on '), + )?.[0]; + expect(listeningLog).toContain('/mcp/'); + expect(listeningLog).not.toContain(token); + expect(listeningLog).not.toContain(fullUrl); + + const client = new Client( + { + name: 'test-client', + version: '1.0.0', + }, + { + capabilities: {}, + }, + ); + const transport = new StreamableHTTPClientTransport(new URL(server.getUrl())); + + try { + await client.connect(transport); + const tools = await client.listTools(); + expect(tools.tools.filter((tool) => !LOWER_SNAKE_TOOL_NAME.test(tool.name)).map((tool) => tool.name)).toEqual([]); + expect(tools.tools).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'opensumi_discover_capabilities', + }), + expect.objectContaining({ + name: 'opensumi_enable_capability_group', + }), + expect.objectContaining({ + name: 'file_read', + description: 'Read file', + inputSchema: expect.objectContaining({ + type: 'object', + }), + }), + expect.objectContaining({ + name: 'acp_chat_get_session_state', + }), + ]), + ); + expect(tools.tools).toEqual( + expect.not.arrayContaining([ + expect.objectContaining({ + name: 'search_text', + }), + ]), + ); + expect(tools.tools).toEqual( + expect.not.arrayContaining([ + expect.objectContaining({ + name: 'file_delete', + }), + ]), + ); + expect(tools.tools).toEqual( + expect.not.arrayContaining([ + expect.objectContaining({ + name: 'terminal_create', + }), + expect.objectContaining({ + name: 'acp_chat_set_session_mode', + }), + ]), + ); + + for (const helperTool of [ + 'opensumi_discover_capabilities', + 'opensumi_describe_capability_group', + 'opensumi_describe_tool', + ]) { + const helperDescriptionResult = await client.callTool({ + name: 'opensumi_describe_tool', + arguments: { tool: helperTool }, + }); + expect(helperDescriptionResult.isError).toBe(false); + expect(JSON.parse((helperDescriptionResult.content as any)[0].text)).toMatchObject({ + success: true, + result: { + name: helperTool, + group: 'opensumi', + inputSchema: expect.objectContaining({ + type: 'object', + }), + }, + }); + } + + const catalogGroupDescriptionResult = await client.callTool({ + name: 'opensumi_describe_capability_group', + arguments: { group: 'opensumi', includeSchemas: true }, + }); + expect(catalogGroupDescriptionResult.isError).toBe(false); + expect(JSON.parse((catalogGroupDescriptionResult.content as any)[0].text)).toMatchObject({ + success: true, + result: { + group: 'opensumi', + toolCount: expect.any(Number), + tools: expect.arrayContaining([ + expect.objectContaining({ + name: 'opensumi_describe_tool', + inputSchema: expect.objectContaining({ + type: 'object', + }), + }), + ]), + }, + }); + + const discoverResult = await client.callTool({ + name: 'opensumi_discover_capabilities', + arguments: { task: 'search for a symbol' }, + }); + expect(discoverResult.isError).toBe(false); + expect(JSON.parse((discoverResult.content as any)[0].text).result.recommended).toEqual([]); + + const enableResult = await client.callTool({ + name: 'opensumi_enable_capability_group', + arguments: { group: 'search' }, + }); + expect(enableResult.isError).toBe(false); + + const toolsAfterEnable = await client.listTools(); + expect(toolsAfterEnable.tools).toEqual( + expect.not.arrayContaining([ + expect.objectContaining({ + name: 'search_text', + }), + ]), + ); + + const describeGroupResult = await client.callTool({ + name: 'opensumi_describe_capability_group', + arguments: { group: 'search' }, + }); + expect(describeGroupResult.isError).toBe(false); + const describedGroup = JSON.parse((describeGroupResult.content as any)[0].text).result; + expect(describedGroup.tools).toEqual([]); + + const describeToolResult = await client.callTool({ + name: 'opensumi_describe_tool', + arguments: { tool: 'search_text' }, + }); + expect(describeToolResult.isError).toBe(true); + expect(JSON.parse((describeToolResult.content as any)[0].text)).toMatchObject({ + success: false, + error: 'CAPABILITY_NOT_AVAILABLE', + }); + + const enableTerminalResult = await client.callTool({ + name: 'opensumi_enable_capability_group', + arguments: { group: 'terminal' }, + }); + expect(enableTerminalResult.isError).toBe(false); + + const toolsAfterTerminalEnable = await client.listTools(); + expect(toolsAfterTerminalEnable.tools).toEqual( + expect.not.arrayContaining([ + expect.objectContaining({ + name: 'terminal_create', + }), + expect.objectContaining({ + name: 'terminal_run_command', + }), + ]), + ); + + const result = await client.callTool({ + name: 'file_read', + arguments: { path: 'README.md' }, + }); + + expect(caller.executeTool).toHaveBeenCalledWith('file', 'file_read', { path: 'README.md' }, undefined); + expect(result.isError).toBe(false); + expect(result.content).toEqual([ + { + type: 'text', + text: JSON.stringify({ success: true, result: { path: 'README.md', content: 'hello' } }), + }, + ]); + + const hiddenResult = await client.callTool({ + name: 'file_delete', + arguments: { path: 'README.md' }, + }); + expect(hiddenResult.isError).toBe(true); + expect(caller.executeTool).toHaveBeenCalledTimes(1); + + const deniedSearchResult = await client.callTool({ + name: 'opensumi_invoke_capability_tool', + arguments: { tool: 'search_text', arguments: { query: 'foo' } }, + }); + expect(deniedSearchResult.isError).toBe(true); + expect(JSON.parse((deniedSearchResult.content as any)[0].text)).toMatchObject({ + success: false, + error: 'CAPABILITY_NOT_ENABLED', + }); + expect(caller.executeTool).toHaveBeenCalledTimes(1); + + const deniedTerminalResult = await client.callTool({ + name: 'opensumi_invoke_capability_tool', + arguments: { tool: 'terminal_run_command', arguments: { id: '1', command: 'pwd' } }, + }); + expect(deniedTerminalResult.isError).toBe(true); + expect(caller.executeTool).toHaveBeenCalledTimes(1); + + const invalidToolResult = await client.callTool({ + name: 'opensumi_invoke_capability_tool', + arguments: { tool: 'search_text_typo', arguments: { query: 'foo' } }, + }); + expect(invalidToolResult.isError).toBe(true); + expect(JSON.parse((invalidToolResult.content as any)[0].text)).toMatchObject({ + success: false, + error: 'TOOL_NOT_FOUND', + }); + expect(caller.executeTool).toHaveBeenCalledTimes(1); + + const fallbackResult = await client.callTool({ + name: 'opensumi_invoke_capability_tool', + arguments: { tool: 'file_read', arguments: { path: 'README.md' } }, + }); + expect(fallbackResult.isError).toBe(false); + expect(caller.executeTool).toHaveBeenCalledWith('file', 'file_read', { path: 'README.md' }, undefined); + + const nestedFallbackResult = await client.callTool({ + name: 'opensumi_invoke_capability_tool', + arguments: { tool: 'file_read', arguments: { arguments: { path: 'README.md' } } }, + }); + expect(nestedFallbackResult.isError).toBe(false); + expect(caller.executeTool).toHaveBeenLastCalledWith('file', 'file_read', { path: 'README.md' }, undefined); + + const nestedInvocationResult = await client.callTool({ + name: 'opensumi_invoke_capability_tool', + arguments: { arguments: { tool: 'file_read', arguments: { path: 'README.md' } } }, + }); + expect(nestedInvocationResult.isError).toBe(false); + expect(caller.executeTool).toHaveBeenLastCalledWith('file', 'file_read', { path: 'README.md' }, undefined); + + const invalidInvocationResult = await client.callTool({ + name: 'opensumi_invoke_capability_tool', + arguments: { arguments: { query: 'missing tool' } }, + }); + expect(invalidInvocationResult.isError).toBe(true); + expect(JSON.parse((invalidInvocationResult.content as any)[0].text)).toMatchObject({ + success: false, + error: 'INVALID_ARGUMENTS', + }); + } finally { + await client.close(); + await server.dispose(); + } + }); + + it('exposes full-profile file mutation tools through MCP tools/list only in the full profile', async () => { + await expect(listMcpToolNames(createFileMutationGroupDefs('default'))).resolves.not.toEqual( + expect.arrayContaining(FILE_MUTATION_TOOL_NAMES), + ); + await expect(listMcpToolNames(createFileMutationGroupDefs('interactive'))).resolves.not.toEqual( + expect.arrayContaining(FILE_MUTATION_TOOL_NAMES), + ); + await expect(listMcpToolNames(createFileMutationGroupDefs('full'))).resolves.toEqual( + expect.arrayContaining(FILE_MUTATION_TOOL_NAMES), + ); + }); + + it('routes MCP sessions to the browser client id embedded in the connection URL', async () => { + const caller = { + getGroupDefinitions: jest.fn(async (_options?: Record, clientId?: string) => + createFileMutationGroupDefs(clientId === 'client-full' ? 'full' : 'interactive'), + ), + executeTool: jest.fn().mockResolvedValue({ + success: true, + }), + }; + const server = createServer(caller); + await server.start(); + const connection = server.getConnectionInfo('client-full'); + expect(connection.url).toContain('clientId=client-full'); + expect(connection.redactedUrl).toContain('clientId=%3Credacted%3E'); + expect(connection.redactedUrl).not.toContain('client-full'); + + const client = new Client( + { + name: 'test-client', + version: '1.0.0', + }, + { + capabilities: {}, + }, + ); + const transport = new StreamableHTTPClientTransport(new URL(connection.url)); + + try { + await client.connect(transport); + const tools = await client.listTools(); + expect(tools.tools.map((tool) => tool.name)).toEqual(expect.arrayContaining(FILE_MUTATION_TOOL_NAMES)); + expect(caller.getGroupDefinitions).toHaveBeenCalledWith({ includeAllTools: true }, 'client-full'); + + const fallbackResult = await client.callTool({ + name: 'opensumi_invoke_capability_tool', + arguments: { tool: 'file_create', arguments: { path: '.tmp/acp-bdd/source.txt', content: 'hello' } }, + }); + expect(fallbackResult.isError).toBe(false); + expect(caller.executeTool).toHaveBeenCalledWith( + 'file', + 'file_create', + { path: '.tmp/acp-bdd/source.txt', content: 'hello' }, + 'client-full', + ); + } finally { + await client.close(); + await server.dispose(); + } + }); + + it('exposes full-profile editor and terminal mutation tools through MCP tools/list only in the full profile', async () => { + await expect(listMcpToolNames(createEditorTerminalMutationGroupDefs('default'))).resolves.not.toEqual( + expect.arrayContaining(EDITOR_TERMINAL_MUTATION_TOOL_NAMES), + ); + await expect(listMcpToolNames(createEditorTerminalMutationGroupDefs('interactive'))).resolves.not.toEqual( + expect.arrayContaining(EDITOR_TERMINAL_MUTATION_TOOL_NAMES), + ); + await expect(listMcpToolNames(createEditorTerminalMutationGroupDefs('full'))).resolves.toEqual( + expect.arrayContaining(EDITOR_TERMINAL_MUTATION_TOOL_NAMES), + ); + }); +}); 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-bdd-runtime-fixtures.ts b/packages/ai-native/src/browser/acp/acp-bdd-runtime-fixtures.ts new file mode 100644 index 0000000000..860d6a7cde --- /dev/null +++ b/packages/ai-native/src/browser/acp/acp-bdd-runtime-fixtures.ts @@ -0,0 +1,27 @@ +export const ACP_BDD_BACKEND_READY_FAILURE_QUERY_PARAM = 'acpBddBackendReadyFailure'; +export const ACP_BDD_BACKEND_READY_FAILURE_QUERY_VALUE = 'reject'; + +const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']); + +export function canUseAcpBddRuntimeFixture(hostname: string | undefined): boolean { + return Boolean(hostname && LOOPBACK_HOSTS.has(hostname)); +} + +function getBrowserLocation(): Location | undefined { + return typeof window === 'undefined' ? undefined : window.location; +} + +export function shouldForceAcpBackendReadinessFailure( + search: string | undefined = getBrowserLocation()?.search, + hostname: string | undefined = getBrowserLocation()?.hostname, +): boolean { + if (!search || !canUseAcpBddRuntimeFixture(hostname)) { + return false; + } + + const params = new URLSearchParams(search); + return ( + params.get('aiNative') === 'true' && + params.get(ACP_BDD_BACKEND_READY_FAILURE_QUERY_PARAM) === ACP_BDD_BACKEND_READY_FAILURE_QUERY_VALUE + ); +} diff --git a/packages/ai-native/src/browser/acp/acp-chat-relay-store.ts b/packages/ai-native/src/browser/acp/acp-chat-relay-store.ts new file mode 100644 index 0000000000..d2e7227f11 --- /dev/null +++ b/packages/ai-native/src/browser/acp/acp-chat-relay-store.ts @@ -0,0 +1,69 @@ +import { Injectable } from '@opensumi/di'; +import { uuid } from '@opensumi/ide-core-common'; + +export interface AcpChatRelayRecord { + digestId: string; + sourceSessionId: string; + sourceTitle: string; + digestSource: 'memory_summary' | 'background_summary' | 'empty'; + digest: string; + sourceChars: number; + digestChars: number; + sourceTruncated: boolean; + createdAt: number; + expiresAt: number; +} + +export interface AcpChatRelayPutOptions { + sourceSessionId: string; + sourceTitle: string; + digestSource: AcpChatRelayRecord['digestSource']; + digest: string; + sourceChars: number; + digestChars: number; + sourceTruncated: boolean; + ttlMs?: number; +} + +const DEFAULT_RELAY_TTL_MS = 10 * 60 * 1000; + +@Injectable() +export class AcpChatRelayStore { + private readonly records = new Map(); + + put(options: AcpChatRelayPutOptions): AcpChatRelayRecord { + this.cleanup(); + const now = Date.now(); + const record: AcpChatRelayRecord = { + digestId: uuid(12), + sourceSessionId: options.sourceSessionId, + sourceTitle: options.sourceTitle, + digestSource: options.digestSource, + digest: options.digest, + sourceChars: options.sourceChars, + digestChars: options.digestChars, + sourceTruncated: options.sourceTruncated, + createdAt: now, + expiresAt: now + (options.ttlMs ?? DEFAULT_RELAY_TTL_MS), + }; + this.records.set(record.digestId, record); + return record; + } + + get(digestId: string): AcpChatRelayRecord | undefined { + this.cleanup(); + return this.records.get(digestId); + } + + delete(digestId: string): void { + this.records.delete(digestId); + } + + private cleanup(now = Date.now()): void { + for (const [digestId, record] of this.records) { + if (record.expiresAt <= now) { + this.records.delete(digestId); + } + } + } +} diff --git a/packages/ai-native/src/browser/acp/acp-chat-relay-summary-provider.ts b/packages/ai-native/src/browser/acp/acp-chat-relay-summary-provider.ts new file mode 100644 index 0000000000..ccc3f51837 --- /dev/null +++ b/packages/ai-native/src/browser/acp/acp-chat-relay-summary-provider.ts @@ -0,0 +1,341 @@ +import { Autowired, Injectable } from '@opensumi/di'; +import { AIBackSerivcePath, IACPConfigProvider, IAIBackService, ILogger } from '@opensumi/ide-core-common'; +import { ChatMessageRole, IHistoryChatMessage } from '@opensumi/ide-core-common/lib/types/ai-native'; + +type AcpChatRelayDigestSource = 'memory_summary' | 'background_summary' | 'empty'; + +interface AcpChatRelayMemorySummary { + content: string; + timestamp: number; + messageIds: string[]; +} + +export interface AcpChatRelaySummarySession { + sessionId: string; + title?: string; + history: { + getMemorySummaries(): AcpChatRelayMemorySummary[]; + getMessages(): IHistoryChatMessage[]; + }; +} + +export interface AcpChatRelaySummaryOptions { + maxSourceChars?: number; + maxDigestChars?: number; +} + +export interface AcpChatRelaySummaryResult { + digestSource: AcpChatRelayDigestSource; + digest: string; + digestChars: number; + sourceChars: number; + sourceTruncated: boolean; +} + +interface BoundedSourceMaterial { + messages: Array<{ role: ChatMessageRole; content: string }>; + sourceChars: number; + sourceTruncated: boolean; +} + +const DEFAULT_MAX_SOURCE_CHARS = 12000; +const MAX_SOURCE_CHARS_CAP = 30000; +const DEFAULT_MAX_DIGEST_CHARS = 2000; +const MAX_DIGEST_CHARS_CAP = 6000; +const MAX_MESSAGE_CHARS = 800; + +function stripAcpPrefix(sessionId: string | undefined): string | undefined { + return sessionId?.startsWith('acp:') ? sessionId.slice(4) : sessionId; +} + +@Injectable() +export class AcpChatRelaySummaryProvider { + @Autowired(ILogger) + private readonly logger: ILogger; + + @Autowired(AIBackSerivcePath) + private readonly aiBackService: IAIBackService; + + @Autowired(IACPConfigProvider) + private readonly configProvider: IACPConfigProvider | undefined; + + async prepareSessionDigest( + session: AcpChatRelaySummarySession, + options: AcpChatRelaySummaryOptions = {}, + ): Promise { + const startedAt = Date.now(); + const limits = this.normalizeLimits(options); + this.log( + `[WebMCP][acp_chat][relay_summary] prepare start — sourceSessionId=${stripAcpPrefix( + session.sessionId, + )}, maxSourceChars=${limits.maxSourceChars}, maxDigestChars=${ + limits.maxDigestChars + }, memorySummaries=${this.getMemorySummaryCount(session)}, historyMessages=${this.getHistoryMessageCount( + session, + )}`, + ); + + const memoryDigest = this.buildMemoryDigest(session, limits.maxDigestChars); + if (memoryDigest) { + const sourceChars = this.getMemorySourceChars(session); + const sourceTruncated = sourceChars > limits.maxDigestChars; + this.log( + `[WebMCP][acp_chat][relay_summary] prepare done — sourceSessionId=${stripAcpPrefix( + session.sessionId, + )}, digestSource=memory_summary, sourceChars=${sourceChars}, digestChars=${ + memoryDigest.length + }, sourceTruncated=${sourceTruncated}, durationMs=${Date.now() - startedAt}`, + ); + return { + digestSource: 'memory_summary', + digest: memoryDigest, + digestChars: memoryDigest.length, + sourceChars, + sourceTruncated, + }; + } + + const source = this.buildBoundedSourceMaterial(session, limits.maxSourceChars); + if (source.messages.length === 0) { + this.warn( + `[WebMCP][acp_chat][relay_summary] prepare empty — sourceSessionId=${stripAcpPrefix( + session.sessionId, + )}, reason=no_source_messages, sourceChars=${source.sourceChars}, sourceTruncated=${ + source.sourceTruncated + }, durationMs=${Date.now() - startedAt}`, + ); + return this.emptyResult(source); + } + + const digest = await this.summarizeMessages(session, source.messages, limits.maxDigestChars); + if (!digest) { + this.warn( + `[WebMCP][acp_chat][relay_summary] prepare empty — sourceSessionId=${stripAcpPrefix( + session.sessionId, + )}, reason=summary_unavailable, messageCount=${source.messages.length}, sourceChars=${ + source.sourceChars + }, sourceTruncated=${source.sourceTruncated}, durationMs=${Date.now() - startedAt}`, + ); + return this.emptyResult(source); + } + + this.log( + `[WebMCP][acp_chat][relay_summary] prepare done — sourceSessionId=${stripAcpPrefix( + session.sessionId, + )}, digestSource=background_summary, messageCount=${source.messages.length}, sourceChars=${ + source.sourceChars + }, digestChars=${digest.length}, sourceTruncated=${source.sourceTruncated}, durationMs=${Date.now() - startedAt}`, + ); + + return { + digestSource: 'background_summary', + digest, + digestChars: digest.length, + sourceChars: source.sourceChars, + sourceTruncated: source.sourceTruncated, + }; + } + + private async summarizeMessages( + session: AcpChatRelaySummarySession, + messages: Array<{ role: ChatMessageRole; content: string }>, + maxDigestChars: number, + ): Promise { + const requestId = `acp_chat_prepare_digest_${Date.now()}`; + const startedAt = Date.now(); + const sourceChars = messages.reduce((total, message) => total + message.content.length, 0); + const prompt = `/compact + +Summarize this ACP chat session for forwarding into another OpenSumi chat. + +Requirements: +- Write concise Chinese unless the source is clearly English-only. +- Preserve concrete decisions, completed work, blockers, and next steps. +- Do not include raw tool outputs, secrets, credentials, or long code blocks. +- Do not mention that this is a summary task. +- Keep the result under ${maxDigestChars} characters.`; + + try { + const agentSessionConfig = await this.configProvider?.resolveConfig(); + const requestInput = `${prompt} + +Source session: +- id: ${session.sessionId} +- title: ${session.title || '(untitled)'} + +Messages: +${this.formatMessagesForSummary(messages)}`; + + this.log( + `[WebMCP][acp_chat][relay_summary] request start — requestId=${requestId}, sourceSessionId=${stripAcpPrefix( + session.sessionId, + )}, messageCount=${messages.length}, sourceChars=${sourceChars}, requestChars=${ + requestInput.length + }, maxDigestChars=${maxDigestChars}`, + ); + + const result = await this.aiBackService.request(requestInput, { + type: 'acp_chat_relay_summary', + requestId, + sessionId: session.sessionId, + messages, + noTool: true, + agentSessionConfig, + }); + + if (result.isCancel || result.errorCode !== 0 || !result.data) { + this.warn( + `[WebMCP][acp_chat][relay_summary] request done — requestId=${requestId}, success=false, isCancel=${Boolean( + result.isCancel, + )}, errorCode=${result.errorCode}, hasData=${Boolean(result.data)}, durationMs=${Date.now() - startedAt}`, + ); + return ''; + } + const digest = this.truncate(String(result.data), maxDigestChars); + this.log( + `[WebMCP][acp_chat][relay_summary] request done — requestId=${requestId}, success=true, resultChars=${ + String(result.data).length + }, digestChars=${digest.length}, durationMs=${Date.now() - startedAt}`, + ); + return digest; + } catch (err) { + this.warn( + `[WebMCP][acp_chat][relay_summary] request error — requestId=${requestId}, errorName=${ + err instanceof Error ? err.name : typeof err + }, durationMs=${Date.now() - startedAt}`, + ); + return ''; + } + } + + private formatMessagesForSummary(messages: Array<{ role: ChatMessageRole; content: string }>): string { + return messages + .map((message) => { + const role = message.role === ChatMessageRole.User ? 'User' : 'Assistant'; + return `[${role}]\n${message.content}`; + }) + .join('\n\n'); + } + + private buildMemoryDigest(session: AcpChatRelaySummarySession, maxDigestChars: number): string { + const digest = session.history + .getMemorySummaries() + .sort((a, b) => a.timestamp - b.timestamp) + .map((summary) => this.normalizeMemoryContent(summary.content)) + .filter(Boolean) + .join('\n\n'); + return this.truncate(digest, maxDigestChars); + } + + private normalizeMemoryContent(content: string): string { + try { + const parsed = JSON.parse(content); + if (typeof parsed.memory === 'string') { + return parsed.memory; + } + if (typeof parsed.content === 'string') { + return parsed.content; + } + } catch { + // Use raw memory text. + } + return content; + } + + private getMemorySourceChars(session: AcpChatRelaySummarySession): number { + return session.history.getMemorySummaries().reduce((total, summary) => total + summary.content.length, 0); + } + + private getMemorySummaryCount(session: AcpChatRelaySummarySession): number { + return session.history.getMemorySummaries().length; + } + + private getHistoryMessageCount(session: AcpChatRelaySummarySession): number { + return session.history.getMessages().length; + } + + private buildBoundedSourceMaterial( + session: AcpChatRelaySummarySession, + maxSourceChars: number, + ): BoundedSourceMaterial { + let sourceChars = 0; + let sourceTruncated = false; + const messages: Array<{ role: ChatMessageRole; content: string }> = []; + const sourceMessages = session.history + .getMessages() + .filter((message) => message.role === ChatMessageRole.User || message.role === ChatMessageRole.Assistant) + .reverse(); + + for (const message of sourceMessages) { + if (!message.content) { + continue; + } + const clippedContent = this.truncate(message.content, MAX_MESSAGE_CHARS); + const nextSize = sourceChars + clippedContent.length; + if (nextSize > maxSourceChars) { + sourceTruncated = true; + break; + } + sourceChars = nextSize; + messages.push({ role: message.role, content: clippedContent }); + if (message.content.length > clippedContent.length) { + sourceTruncated = true; + } + } + + return { + messages: messages.reverse(), + sourceChars, + sourceTruncated, + }; + } + + private emptyResult(source: BoundedSourceMaterial): AcpChatRelaySummaryResult { + return { + digestSource: 'empty', + digest: '', + digestChars: 0, + sourceChars: source.sourceChars, + sourceTruncated: source.sourceTruncated, + }; + } + + private normalizeLimits(options: AcpChatRelaySummaryOptions): Required { + return { + maxSourceChars: this.toPositiveCappedNumber( + options.maxSourceChars, + DEFAULT_MAX_SOURCE_CHARS, + MAX_SOURCE_CHARS_CAP, + ), + maxDigestChars: this.toPositiveCappedNumber( + options.maxDigestChars, + DEFAULT_MAX_DIGEST_CHARS, + MAX_DIGEST_CHARS_CAP, + ), + }; + } + + private toPositiveCappedNumber(value: unknown, fallback: number, cap: number): number { + return Math.min(Math.max(Number(value) || fallback, 1), cap); + } + + private truncate(value: string, maxChars: number): string { + return value.length > maxChars ? value.slice(0, maxChars) : value; + } + + private log(message: string): void { + try { + this.logger?.log?.(message); + } catch { + // Logger injection is optional for isolated unit tests. + } + } + + private warn(message: string): void { + try { + this.logger?.warn?.(message); + } catch { + // Logger injection is optional for isolated unit tests. + } + } +} 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..4e824d3a1b --- /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 { WebMcpGroupDefinitionOptions, 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(options?: WebMcpGroupDefinitionOptions): Promise { + return this.registry.getGroupDefinitions(options); + } + + 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..dc37b09c17 --- /dev/null +++ b/packages/ai-native/src/browser/acp/build-agent-process-config.ts @@ -0,0 +1,84 @@ +import { DEFAULT_ACP_THREAD_POOL_SIZE } from '@opensumi/ide-core-common/lib/settings/ai-native'; +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< + string, + { + command?: string; + args?: string[]; + env?: Record; + defaultModel?: string; + defaultMode?: string; + defaultConfigOptions?: Record; + } + >; + threadPoolSize?: number; + webMcpEnabled?: boolean; + }; + 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, + threadPoolSize: normalizeThreadPoolSize(input.userPreferences.threadPoolSize), + }; + if (input.mcpServers) { + config.mcpServers = input.mcpServers; + } + if (typeof input.userPreferences.webMcpEnabled === 'boolean') { + config.webMcp = { + enabled: input.userPreferences.webMcpEnabled, + }; + } + if (override.defaultModel) { + config.defaultModel = override.defaultModel; + } + if (override.defaultMode) { + config.defaultMode = override.defaultMode; + } + if (override.defaultConfigOptions) { + config.defaultConfigOptions = override.defaultConfigOptions; + } + return config; +} + +function normalizeThreadPoolSize(value: number | undefined): number { + if (typeof value !== 'number' || !Number.isFinite(value) || value < 1) { + return DEFAULT_ACP_THREAD_POOL_SIZE; + } + return Math.floor(value); +} + +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..6d64002cc8 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; + createdAt: number; loading: boolean; + threadStatus?: ThreadStatus; + hasPendingPermission?: boolean; } export interface IChatHistoryProps { @@ -19,9 +44,14 @@ export interface IChatHistoryProps { historyList: IChatHistoryItem[]; currentId?: string; className?: string; + variant?: 'popover' | 'inline'; historyLoading?: boolean; disabled?: boolean; + historyCollapsed?: boolean; + pendingPermissionBadge?: number; onNewChat: () => void; + onOpenMCPConfig?: () => void; + onToggleHistoryCollapsed?: () => void; onHistoryItemSelect: (item: IChatHistoryItem) => void; onHistoryItemDelete?: (item: IChatHistoryItem) => void; onHistoryItemChange: (item: IChatHistoryItem, title: string) => void; @@ -41,12 +71,17 @@ const AcpChatHistory: FC = memo( historyList, currentId, onNewChat, + onOpenMCPConfig, onHistoryItemSelect, onHistoryItemChange, onHistoryPopoverVisibleChange, historyLoading, disabled, + historyCollapsed, className, + variant = 'popover', + pendingPermissionBadge, + onToggleHistoryCollapsed, }) => { const [historyTitleEditable, setHistoryTitleEditable] = useState<{ [key: string]: boolean; @@ -110,6 +145,12 @@ const AcpChatHistory: FC = memo( } }, [historyTitleEditable]); + useEffect(() => { + if (variant === 'inline') { + onHistoryPopoverVisibleChange?.(true); + } + }, [onHistoryPopoverVisibleChange, variant]); + // 获取时间标签 const getTimeKey = useCallback((diff: number): string => { if (diff < 60 * 60 * 1000) { @@ -139,8 +180,8 @@ const AcpChatHistory: FC = memo( 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 createdAt = new Date(item.createdAt); + const diff = now.getTime() - createdAt.getTime(); const key = getTimeKey(diff); const existingGroup = result.find((group) => group.key === key); @@ -161,18 +202,39 @@ const AcpChatHistory: FC = memo( (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 renderHistory = useCallback(() => { const filteredList = historyList - .slice(-MAX_HISTORY_LIST) - .reverse() - .filter((item) => item.title && item.title.includes(searchValue)); + .map((item, index) => ({ item, index })) + .sort((a, b) => { + if (a.item.createdAt && b.item.createdAt && a.item.createdAt !== b.item.createdAt) { + return b.item.createdAt - a.item.createdAt; + } + if (a.item.createdAt && !b.item.createdAt) { + return -1; + } + if (!a.item.createdAt && b.item.createdAt) { + return 1; + } + return b.index - a.index; + }) + .slice(0, MAX_HISTORY_LIST) + .map(({ item }) => item) + .filter((item) => item.title !== undefined && item.title.includes(searchValue)); const groupedHistoryList = formatHistory(filteredList); return ( -
+
-
+
{historyLoading ? (
@@ -231,54 +313,144 @@ const AcpChatHistory: FC = memo(
); - }, [historyList, searchValue, formatHistory, handleSearchChange, renderHistoryItem, historyLoading, disabled]); + }, [ + historyList, + searchValue, + formatHistory, + handleSearchChange, + renderHistoryItem, + historyLoading, + disabled, + variant, + ]); // getPopupContainer 处理函数 const getPopupContainer = useCallback((triggerNode: HTMLElement) => triggerNode.parentElement!, []); - return ( -
+ const renderNewChatAction = () => ( + + {disabled ? ( +
+ +
+ ) : ( + + )} +
+ ); + + const renderMCPConfigAction = () => { + if (variant !== 'inline' || !onOpenMCPConfig) { + return null; + } + + const mcpConfigTitle = localize('ai.native.mcp.config.title'); + + return ( + + + + ); + }; + + const renderCollapseAction = () => { + if (variant !== 'inline' || !onToggleHistoryCollapsed) { + return null; + } + + const collapseTitle = historyCollapsed + ? localize('aiNative.operate.chatHistory.expand', 'Expand Chat History') + : localize('aiNative.operate.chatHistory.collapse', 'Collapse Chat History'); + + return ( + + + + ); + }; + + const renderHeader = () => ( +
- {title} + {variant === 'inline' ? ( +
+ {renderCollapseAction()} + {renderNewChatAction()} + {renderMCPConfigAction()} +
+ ) : ( + {title} + )} + {variant === 'inline' && pendingPermissionBadge && pendingPermissionBadge > 0 ? ( + + {pendingPermissionBadge > 99 ? '99+' : pendingPermissionBadge} + + ) : null}
-
- -
+ - -
-
- - {disabled ? ( -
- +
+
+ + {pendingPermissionBadge && pendingPermissionBadge > 0 ? ( + + {pendingPermissionBadge > 99 ? '99+' : pendingPermissionBadge} + + ) : null} +
- ) : ( - - )} - -
+
+ {renderNewChatAction()} +
+ ) : null}
); + + if (variant === 'inline') { + return ( +
+ {renderHeader()} + {!historyCollapsed && renderHistory()} +
+ ); + } + + return
{renderHeader()}
; }, ); diff --git a/packages/ai-native/src/browser/acp/components/AcpChatInput.tsx b/packages/ai-native/src/browser/acp/components/AcpChatInput.tsx index be081892a7..abe83f579c 100644 --- a/packages/ai-native/src/browser/acp/components/AcpChatInput.tsx +++ b/packages/ai-native/src/browser/acp/components/AcpChatInput.tsx @@ -28,8 +28,9 @@ import { ChatSlashCommandItemModel } from '../../chat/chat-model'; import { ChatProxyService } from '../../chat/chat-proxy.service'; import { ChatFeatureRegistry } from '../../chat/chat.feature.registry'; import { AcpChatInternalService } from '../../chat/chat.internal.service.acp'; +import { hasAcpChatSendPayload } from '../../components/acp/chat-input-validation'; import styles from '../../components/components.module.less'; -import { MCPConfigCommands } from '../../mcp/config/mcp-config.commands'; +import { MCPConfigCommands } from '../../mcp/config/mcp-config.constants'; import { MCPServerProxyService } from '../../mcp/mcp-server-proxy.service'; import { MCPToolsDialog } from '../../mcp/mcp-tools-dialog.view'; import { IChatSlashCommandItem } from '../../types'; @@ -339,6 +340,9 @@ export const AcpChatInput = React.forwardRef((props: IAcpChatInputProps, ref) => } const handleSendLogic = (newValue: string = value) => { + if (!hasAcpChatSendPayload({ message: newValue, command })) { + return; + } onSend(newValue, [], agentId, command); setValue(''); setTheme(''); @@ -459,7 +463,7 @@ export const AcpChatInput = React.forwardRef((props: IAcpChatInputProps, ref) => }, [isExpand]); return ( -
+
{isShowOptions && (
void; disableModelSelector?: boolean; sessionModelId?: string; + currentModeId?: string; + agentModels?: AcpSessionModelOption[]; + currentModelId?: string; + configOptions?: AcpSessionConfigOption[]; contextService?: LLMContextService; agentModes?: Array<{ id: string; name: string; description?: string }>; agentCwd?: string; @@ -82,12 +90,12 @@ export interface IChatMentionInputProps { * - 文件选择器:无搜索词时递归加载工作区文件(限制 50 个) * - 文件夹选择器:无搜索词时加载工作区根目录下的文件夹 */ -export const AcpChatMentionInput = (props: IChatMentionInputProps) => { +export const AcpChatMentionInput = React.forwardRef((props: IChatMentionInputProps, ref) => { const { onSend, disabled = false, contextService, agentCwd } = props; const [value, setValue] = useState(props.value || ''); const [images, setImages] = useState(props.images || []); - const [currentMode, setCurrentMode] = useState(props.agentModes?.[0]?.id || 'default'); + const [currentMode, setCurrentMode] = useState(props.currentModeId || props.agentModes?.[0]?.id || 'default'); const aiChatService = useInjectable(IChatInternalService); const aiNativeConfigService = useInjectable(AINativeConfigService); const commandService = useInjectable(CommandService); @@ -107,6 +115,7 @@ export const AcpChatMentionInput = (props: IChatMentionInputProps) => { props.placeholder || localize('aiNative.chat.input.placeholder.default'), ); const [defaultInput, setDefaultInput] = useState(''); + const [isExpanded, setIsExpanded] = useState(false); const preferenceService = useInjectable(PreferenceService); const rulesService = useInjectable(RulesServiceToken); @@ -128,10 +137,14 @@ export const AcpChatMentionInput = (props: IChatMentionInputProps) => { // 当 agentModes 变化时,更新 currentMode 为第一个 mode useEffect(() => { + if (props.currentModeId) { + setCurrentMode(props.currentModeId); + return; + } if (props.agentModes?.length && !props.agentModes.find((m) => m.id === currentMode)) { setCurrentMode(props.agentModes[0].id); } - }, [props.agentModes]); + }, [props.agentModes, props.currentModeId]); // 当 slash command 变化时,更新 placeholder 和 defaultInput useEffect(() => { @@ -161,6 +174,18 @@ export const AcpChatMentionInput = (props: IChatMentionInputProps) => { } }, [props.value]); + React.useImperativeHandle( + ref, + () => ({ + setInputValue: (inputValue: string) => { + setDefaultInput(inputValue); + setValue(inputValue); + props.onValueChange?.(inputValue); + }, + }), + [props.onValueChange], + ); + const resolveSymbols = useCallback( async (parent?: OutlineCompositeTreeNode, symbols: (OutlineTreeNode | OutlineCompositeTreeNode)[] = []) => { if (!parent) { @@ -536,13 +561,24 @@ export const AcpChatMentionInput = (props: IChatMentionInputProps) => { }, ]; + const hasConfigOptions = (props.configOptions?.length || 0) > 0; + // Mode 选项 const modeOptions: ModeOption[] = useMemo( + () => (hasConfigOptions ? [] : props.agentModes || []), + [hasConfigOptions, props.agentModes], + ); + + const modelOptions = useMemo( () => - props.agentModes?.length - ? props.agentModes - : [{ id: 'default', name: 'Default', description: 'Require approval for edits' }], - [props.agentModes], + hasConfigOptions + ? [] + : props.agentModels?.map((model) => ({ + value: model.modelId, + label: model.name || model.modelId, + description: model.description || undefined, + })) || [], + [hasConfigOptions, props.agentModels], ); const slashCommands = useMemo( @@ -587,43 +623,48 @@ export const AcpChatMentionInput = (props: IChatMentionInputProps) => { defaultMode: modeOptions[0]?.id || 'default', currentMode, showModeSelector: modeOptions.length > 1, - modelOptions: [ - { - value: 'qwen-plus-latest', - label: 'Qwen 3', - iconClass: iconService.fromIcon( - '', - 'https://img.alicdn.com/imgextra/i3/O1CN01LFMrZj28YrnrzeebY_!!6000000007945-55-tps-16-16.svg', - IconType.Background, - ), - tags: ['思考链', '擅长代码'], - description: '高性能代码模型,支持思考链', - }, - { - label: 'Claude 4 Sonnet', - value: 'claude_sonnet4', - iconClass: iconService.fromIcon( - '', - 'https://img.alicdn.com/imgextra/i3/O1CN01p0mziz1Nsl40lp1HO_!!6000000001626-55-tps-92-65.svg', - IconType.Background, - ), - tags: ['多模态', '长上下文理解', '思考模式'], - description: '高性能模型,支持多模态输入', - }, - { - label: 'DeepSeek R1', - value: 'DeepSeek-R1-0528', - iconClass: iconService.fromIcon( - '', - 'https://img.alicdn.com/imgextra/i3/O1CN01ClcK2w1JwdxcbAB3a_!!6000000001093-55-tps-30-30.svg', - IconType.Background, - ), - tags: ['思考模式', '长上下文理解'], - description: '专业创作,支持多模态输入', - }, - ], + modelOptions: aiNativeConfigService.capabilities.supportsAgentMode + ? modelOptions + : [ + { + value: 'qwen-plus-latest', + label: 'Qwen 3', + iconClass: iconService.fromIcon( + '', + 'https://img.alicdn.com/imgextra/i3/O1CN01LFMrZj28YrnrzeebY_!!6000000007945-55-tps-16-16.svg', + IconType.Background, + ), + tags: ['思考链', '擅长代码'], + description: '高性能代码模型,支持思考链', + }, + { + label: 'Claude 4 Sonnet', + value: 'claude_sonnet4', + iconClass: iconService.fromIcon( + '', + 'https://img.alicdn.com/imgextra/i3/O1CN01p0mziz1Nsl40lp1HO_!!6000000001626-55-tps-92-65.svg', + IconType.Background, + ), + tags: ['多模态', '长上下文理解', '思考模式'], + description: '高性能模型,支持多模态输入', + }, + { + label: 'DeepSeek R1', + value: 'DeepSeek-R1-0528', + iconClass: iconService.fromIcon( + '', + 'https://img.alicdn.com/imgextra/i3/O1CN01ClcK2w1JwdxcbAB3a_!!6000000001093-55-tps-30-30.svg', + IconType.Background, + ), + tags: ['思考模式', '长上下文理解'], + description: '专业创作,支持多模态输入', + }, + ], defaultModel: - props.sessionModelId || preferenceService.get(AINativeSettingSectionsId.ModelID) || 'deepseek-r1', + props.currentModelId || + props.sessionModelId || + preferenceService.get(AINativeSettingSectionsId.ModelID) || + 'deepseek-r1', buttons: aiNativeConfigService.capabilities.supportsAgentMode ? [] : [ @@ -660,8 +701,9 @@ export const AcpChatMentionInput = (props: IChatMentionInputProps) => { position: FooterButtonPosition.LEFT, }, ], - showModelSelector: aiNativeConfigService.capabilities.supportsAgentMode ? false : true, + showModelSelector: aiNativeConfigService.capabilities.supportsAgentMode ? modelOptions.length > 0 : true, disableModelSelector: props.disableModelSelector, + configOptions: props.configOptions, }), [ iconService, @@ -669,8 +711,11 @@ export const AcpChatMentionInput = (props: IChatMentionInputProps) => { handleShowRules, props.disableModelSelector, props.sessionModelId, + props.currentModelId, + props.configOptions, currentMode, modeOptions, + modelOptions, aiNativeConfigService.capabilities.supportsAgentMode, preferenceService, ], @@ -690,18 +735,17 @@ export const AcpChatMentionInput = (props: IChatMentionInputProps) => { const currentAgentId = props.agentId; const doSend = (newValue: string = content) => { - onSend( - newValue, - images.map((image) => image.toString()), - currentAgentId, - currentCommand, - option, - ); + const imagePayload = images.map((image) => image.toString()); + if (!hasAcpChatSendPayload({ message: newValue, images: imagePayload, command: currentCommand })) { + return; + } + const sendResult = onSend(newValue, imagePayload, currentAgentId, currentCommand, option); // 发送后重置 slash command 状态 props.setTheme(null); props.setAgentId(''); props.setCommand(''); setImages(props.images || []); + return sendResult; }; // 如果有 slash command,调用其 execute handler @@ -714,7 +758,7 @@ export const AcpChatMentionInput = (props: IChatMentionInputProps) => { } } - doSend(); + return doSend(); }, [onSend, images, disabled, props.agentId, props.command, chatFeatureRegistry], ); @@ -754,6 +798,30 @@ export const AcpChatMentionInput = (props: IChatMentionInputProps) => { [aiChatService, messageService], ); + const handleModelChange = useCallback( + async (modelId: string) => { + try { + await aiChatService.setSessionModel(modelId); + } catch (error) { + messageService.error('Failed to switch model: ' + (error instanceof Error ? error.message : String(error))); + } + }, + [aiChatService, messageService], + ); + + const handleConfigOptionChange = useCallback( + async (configId: string, value: boolean | string) => { + try { + await aiChatService.setSessionConfigOption(configId, value); + } catch (error) { + messageService.error( + 'Failed to update ACP config: ' + (error instanceof Error ? error.message : String(error)), + ); + } + }, + [aiChatService, messageService], + ); + const handleDeleteImage = useCallback( (index: number) => { setImages(images.filter((_, i) => i !== index)); @@ -773,33 +841,56 @@ export const AcpChatMentionInput = (props: IChatMentionInputProps) => { [chatFeatureRegistry], ); + const handleExpandClick = useCallback(() => { + const nextExpanded = !isExpanded; + setIsExpanded(nextExpanded); + props.onExpand?.(nextExpanded); + }, [isExpanded, props.onExpand]); + return ( -
+
+
+ + + +
{images.length > 0 && } - chatRenderRegistry.enabledMentionTypes!.includes(item.id)) - : defaultMenuItems - } - slashCommands={[...slashCommands, ...acpSlashCommands]} - onSend={handleSend} - onStop={handleStop} - loading={disabled} - labelService={labelService} - workspaceService={workspaceService} - placeholder={placeholder} - footerConfig={defaultMentionInputFooterOptions} - onImageUpload={handleImageUpload} - contextService={contextService} - onModeChange={handleModeChange} - defaultInput={defaultInput} - onDefaultInputConsumed={() => setDefaultInput('')} - onSlashSelect={handleSlashSelect} - /> +
+ chatRenderRegistry.enabledMentionTypes!.includes(item.id)) + : defaultMenuItems + } + slashCommands={[...slashCommands, ...acpSlashCommands]} + onSend={handleSend} + onStop={handleStop} + loading={disabled} + labelService={labelService} + workspaceService={workspaceService} + placeholder={placeholder} + footerConfig={defaultMentionInputFooterOptions} + onImageUpload={handleImageUpload} + modeOptions={modeOptions} + currentMode={currentMode} + configOptions={props.configOptions} + onSelectionChange={handleModelChange} + contextService={contextService} + onModeChange={handleModeChange} + onConfigOptionChange={handleConfigOptionChange} + defaultInput={defaultInput} + onDefaultInputConsumed={() => setDefaultInput('')} + onSlashSelect={handleSlashSelect} + expanded={isExpanded} + /> +
); -}; +}); const ImagePreviewer = ({ images, diff --git a/packages/ai-native/src/browser/acp/components/AcpChatViewHeader.tsx b/packages/ai-native/src/browser/acp/components/AcpChatViewHeader.tsx index 5f6b4b7ffc..42801ebe8a 100644 --- a/packages/ai-native/src/browser/acp/components/AcpChatViewHeader.tsx +++ b/packages/ai-native/src/browser/acp/components/AcpChatViewHeader.tsx @@ -1,10 +1,12 @@ +import cls from 'classnames'; import React from 'react'; -import { QuickPickService, getIcon, useInjectable } from '@opensumi/ide-core-browser'; +import { AINativeConfigService, QuickPickService, getIcon, useInjectable } from '@opensumi/ide-core-browser'; import { Popover, PopoverPosition } from '@opensumi/ide-core-browser/lib/components'; import { EnhanceIcon } from '@opensumi/ide-core-browser/lib/components/ai-native'; import { ChatMessageRole, + CommandService, DisposableCollection, IDisposable, formatLocalize, @@ -15,41 +17,71 @@ import { IWorkspaceService } from '@opensumi/ide-workspace'; import { IChatInternalService } from '../../../common'; import { cleanAttachedTextWrapper } from '../../../common/utils'; -import { ChatInternalService } from '../../chat/chat.internal.service'; +import { ChatModel } from '../../chat/chat-model'; import { AcpChatInternalService } from '../../chat/chat.internal.service.acp'; import styles from '../../chat/chat.module.less'; import { getCachedWorkspaceDir, switchWorkspaceDir } from '../../chat/pick-workspace-dir'; +import { AIPanelLayoutService } from '../../layout/panel-layout.service'; +import { MCPConfigCommands } from '../../mcp/config/mcp-config.constants'; +import { AcpPermissionBridgeService } from '../permission-bridge.service'; import AcpChatHistory, { IChatHistoryItem } from './AcpChatHistory'; const MAX_TITLE_LENGTH = 100; +function getSessionCreatedAt(session: ChatModel): number { + const firstMessage = session.history.getMessages()[0]; + return session.createdAt || firstMessage?.timestamp || firstMessage?.replyStartTime || 0; +} + +function getVisibleAcpSessions(aiChatService: AcpChatInternalService): ChatModel[] { + return typeof aiChatService.getVisibleSessions === 'function' + ? aiChatService.getVisibleSessions() + : aiChatService.getSessions(); +} + /** * ACP 专属的 ChatViewHeader * 与 DefaultChatViewHeader 的区别: * - 使用 session.title(服务端返回的标题)构建 historyList,而非从消息内容推导 * - 不显示删除按钮(ACP 模式下由服务端管理会话生命周期) */ -export function AcpChatViewHeader({ - handleClear, - handleCloseChatView, -}: { - handleClear: () => any; - handleCloseChatView: () => any; -}) { +export function AcpChatViewHeader({ handleCloseChatView }: { handleClear: () => any; handleCloseChatView: () => any }) { const aiChatService = useInjectable(IChatInternalService); const messageService = useInjectable(IMessageService); const workspaceService = useInjectable(IWorkspaceService); const quickPick = useInjectable(QuickPickService); + const permissionBridgeService = useInjectable(AcpPermissionBridgeService); + const panelLayoutService = useInjectable(AIPanelLayoutService); + const aiNativeConfigService = useInjectable(AINativeConfigService); + const commandService = useInjectable(CommandService); 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 [panelLayout, setPanelLayout] = React.useState(() => panelLayoutService.getLayoutMode()); + const [historyCollapsed, setHistoryCollapsed] = React.useState(false); const isMultiRoot = workspaceService.isMultiRootWorkspaceOpened; + const subscribedSessionIdsRef = React.useRef>(new Set()); + const toDisposeRef = React.useRef(new DisposableCollection()); + const sessionSwitchingRef = React.useRef(false); + const [currentWorkspaceDir, setCurrentWorkspaceDir] = React.useState(getCachedWorkspaceDir()); + const enterDraftSession = React.useCallback( + (options?: { force?: boolean }) => { + if (sessionSwitchingRef.current) { + return; + } + + aiChatService.enterDraftSession(options); + }, + [aiChatService], + ); + // Sync state when cache is updated externally (e.g. by session provider on first init) React.useEffect(() => { const cached = getCachedWorkspaceDir(); @@ -62,35 +94,32 @@ export function AcpChatViewHeader({ const oldDir = getCachedWorkspaceDir(); const newDir = await switchWorkspaceDir(workspaceService, quickPick, messageService); setCurrentWorkspaceDir(newDir); - // Create new session with new cwd if path actually changed + // Enter a draft; the ACP session will be created with the new cwd on first send if (newDir && newDir !== oldDir) { - try { - aiChatService.createSessionModel(); - } catch (error) { - messageService.error(error.message); - } + enterDraftSession({ force: true }); } - }, [workspaceService, quickPick, messageService, aiChatService]); + }, [workspaceService, quickPick, messageService, enterDraftSession]); React.useEffect(() => { const dispose = aiChatService.onSessionLoadingChange((loading) => { + sessionSwitchingRef.current = loading; setSessionSwitching(loading); }); return () => dispose.dispose(); }, [aiChatService]); + React.useEffect(() => { + const disposable = panelLayoutService.onDidChangePanelLayout((mode) => { + setPanelLayout(mode); + }); + setPanelLayout(panelLayoutService.getLayoutMode()); + + return () => disposable.dispose(); + }, [panelLayoutService]); + const handleNewChat = React.useCallback(() => { - if (sessionSwitching) { - return; - } - if (aiChatService.sessionModel && aiChatService.sessionModel.history.getMessages().length > 0) { - try { - aiChatService.createSessionModel(); - } catch (error) { - messageService.error(error.message); - } - } - }, [aiChatService, sessionSwitching]); + enterDraftSession(); + }, [enterDraftSession]); const handleHistoryItemSelect = React.useCallback( (item: IChatHistoryItem) => { @@ -109,7 +138,22 @@ export function AcpChatViewHeader({ * 优先使用 session.title(服务端元数据),降级使用第一条消息内容 */ const getHistoryList = React.useCallback(async () => { - const sessions = aiChatService.getSessions(); + const sessions = getVisibleAcpSessions(aiChatService); + + // 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() || []; @@ -131,13 +175,15 @@ export function AcpChatViewHeader({ sessionTitle = cleanAttachedTextWrapper(messages[0].content).slice(0, MAX_TITLE_LENGTH); } - const updatedAt = messages.length > 0 ? messages[messages.length - 1].replyStartTime || 0 : 0; + const createdAt = getSessionCreatedAt(session as ChatModel); return { id: session.sessionId, title: sessionTitle, - updatedAt, + createdAt, loading: false, + threadStatus: (session as ChatModel).threadStatus, + hasPendingPermission: permissionBridgeService.hasPendingForSession(session.sessionId), }; }), ); @@ -162,9 +208,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,19 +251,47 @@ export function AcpChatViewHeader({ return () => { toDispose.dispose(); + subscribedSessionIdsRef.current.clear(); }; }, [aiChatService]); + const isAgenticLayout = panelLayout === 'agentic'; + + React.useEffect(() => { + if (!isAgenticLayout) { + setHistoryCollapsed(false); + } + }, [isAgenticLayout]); + + const handleToggleHistoryCollapsed = React.useCallback(() => { + setHistoryCollapsed((collapsed) => !collapsed); + }, []); + + const handleOpenMCPConfig = React.useCallback(() => { + commandService.executeCommand(MCPConfigCommands.OPEN_MCP_CONFIG.id); + }, [commandService]); + return ( -
+
{}} onHistoryItemChange={handleHistoryItemChange} @@ -228,35 +318,23 @@ export function AcpChatViewHeader({ /> )} - - - - - - + {!isAgenticLayout && ( + + + + )}
); } diff --git a/packages/ai-native/src/browser/acp/components/AcpChatViewWrapper.tsx b/packages/ai-native/src/browser/acp/components/AcpChatViewWrapper.tsx index 3c602782d5..7565cbca9c 100644 --- a/packages/ai-native/src/browser/acp/components/AcpChatViewWrapper.tsx +++ b/packages/ai-native/src/browser/acp/components/AcpChatViewWrapper.tsx @@ -3,7 +3,7 @@ * * 为 ACP 模式提供包装层,封装: * - ACP 初始化逻辑(等待 Agent 准备) - * - 等待 sessionModel 准备好 + * - 等待历史会话元数据准备好 * - Loading/Error 状态处理 * - 权限弹窗 * @@ -16,19 +16,21 @@ import { Progress } from '@opensumi/ide-core-browser/lib/progress/progress-bar'; import { AIBackSerivcePath, IAIBackService, localize } from '@opensumi/ide-core-common'; import { ChatProxyServiceToken, IChatManagerService } from '../../../common'; -import { ChatManagerService } from '../../chat/chat-manager.service'; import { AcpChatManagerService } from '../../chat/chat-manager.service.acp'; -import { ChatProxyService } from '../../chat/chat-proxy.service'; import { AcpChatProxyService } from '../../chat/chat-proxy.service.acp'; import { ChatInternalService } from '../../chat/chat.internal.service'; -import { AcpChatInternalService } from '../../chat/chat.internal.service.acp'; import styles from '../../chat/chat.module.less'; +import { shouldForceAcpBackendReadinessFailure } from '../acp-bdd-runtime-fixtures'; interface AcpChatViewWrapperProps { children: React.ReactNode; aiChatService: ChatInternalService; } +type AcpBootstrapChatService = ChatInternalService & { + ensureBootstrapSessionModel?: () => Promise; +}; + export function AcpChatViewWrapper({ children, aiChatService }: AcpChatViewWrapperProps) { const aiNativeConfigService = useInjectable(AINativeConfigService); const aiBackService = useInjectable(AIBackSerivcePath); @@ -42,33 +44,18 @@ export function AcpChatViewWrapper({ children, aiChatService }: AcpChatViewWrapp initialized: false, }); - // ACP 模式:等待 sessionModel 准备好 - const [sessionReady, setSessionReady] = useState(false); - - // 初始化超时状态:超过 30s 未完成时展示重试按钮 - const [timedOut, setTimedOut] = useState(false); - - // 重试 key:变化时触发重新初始化 - const [retryKey, setRetryKey] = useState(0); - - // 用于取消上一轮初始化的 cancelled flag + // 用于取消当前初始化的 cancelled flag const cancelledRef = useRef(false); - // ACP 模式:只在第一次渲染或重试时触发初始化 + // ACP 模式:组件 mount 时触发初始化 useEffect(() => { // 非 ACP 模式不需要延迟初始化 if (!aiNativeConfigService.capabilities.supportsAgentMode) { setInitState({ initialized: true }); - setSessionReady(true); return; } - // 取消上一轮初始化,重置状态 cancelledRef.current = false; - setInitState({ initialized: false }); - setSessionReady(false); - setTimedOut(false); - const cancelled = () => cancelledRef.current; const initializeACP = async () => { @@ -82,6 +69,9 @@ export function AcpChatViewWrapper({ children, aiChatService }: AcpChatViewWrapp if (cancelled()) { return; } + if (shouldForceAcpBackendReadinessFailure()) { + throw new Error('ACP backend readiness failure fixture'); + } const isReady = await aiBackService.ready?.(); ready = !!isReady; @@ -101,15 +91,19 @@ export function AcpChatViewWrapper({ children, aiChatService }: AcpChatViewWrapp // 先调用 aiChatService.init() 注册 onStorageInit 监听器 aiChatService.init(); - // 创建新会话 - await aiChatService.createSessionModel(); + + // 加载历史会话列表(用于 history 下拉展示) + await chatManagerService.loadSessionList(); if (cancelled()) { return; } - // 加载历史会话列表(用于 history 下拉展示) - await chatManagerService.loadSessionList(); + try { + await (aiChatService as AcpBootstrapChatService).ensureBootstrapSessionModel?.(); + } catch { + // Bootstrap is a UX warm-up only. The first real send still creates a session lazily. + } if (cancelled()) { return; @@ -123,91 +117,34 @@ export function AcpChatViewWrapper({ children, aiChatService }: AcpChatViewWrapp // Fallback to default agent when ACP is unavailable chatManagerService.fallbackToLocal(); chatProxyService.registerFallbackAgent(); - // Re-create session model using the local provider - await aiChatService.createSessionModel(); + if (!aiChatService.sessionModel) { + await aiChatService.createSessionModel(); + } setInitState({ initialized: true }); } }; - // 30s 超时 timer - const timeoutTimer = window.setTimeout(() => { - setTimedOut(true); - }, 30000); - initializeACP(); return () => { cancelledRef.current = true; - clearTimeout(timeoutTimer); }; - }, [retryKey]); - - const handleRetry = () => { - setRetryKey((k) => k + 1); - }; + }, []); - // 等待 sessionModel 准备好 - useEffect(() => { - if (!aiNativeConfigService.capabilities.supportsAgentMode) { - setSessionReady(true); - return; - } - - if (!initState.initialized) { - return; - } - - // 检查 sessionModel 是否已准备好 - if (aiChatService.sessionModel) { - setSessionReady(true); - return; - } - - // 轮询检查 sessionModel,直到就绪 - let pollCount = 0; - const MAX_POLL_COUNT = 12000; // 1200s at 100ms intervals - - const interval = window.setInterval(() => { - pollCount++; - if (aiChatService.sessionModel) { - setSessionReady(true); - clearInterval(interval); - return; - } - if (pollCount >= MAX_POLL_COUNT) { - clearInterval(interval); - setInitState({ initialized: true }); - } - }, 100); - - return () => { - clearInterval(interval); - }; - }, [initState.initialized, retryKey]); if (!aiNativeConfigService.capabilities.supportsAgentMode) { return children; } - // ACP 模式或初始化完成且 session 准备好,渲染子组件 - if (initState.initialized && sessionReady) { + // ACP 模式初始化完成后渲染子组件;真正的 session 会在首次发送时创建。 + if (initState.initialized) { return <>{children}; } - // 初始化中或等待 session + // 初始化中 return (
{localize('aiNative.chat.acp.initializing.text', 'Initializing ACP service...')}
- {timedOut && ( - <> -
- {localize('aiNative.chat.acp.timeout.hint', 'Initialization is taking longer than expected')} -
- - - )}
); } diff --git a/packages/ai-native/src/browser/acp/debug-log/acp-debug-log.contribution.ts b/packages/ai-native/src/browser/acp/debug-log/acp-debug-log.contribution.ts new file mode 100644 index 0000000000..d07b5c8ba5 --- /dev/null +++ b/packages/ai-native/src/browser/acp/debug-log/acp-debug-log.contribution.ts @@ -0,0 +1,70 @@ +import { Autowired } from '@opensumi/di'; +import { getIcon } from '@opensumi/ide-components'; +import { CommandContribution, CommandRegistry, Domain, URI } from '@opensumi/ide-core-common'; +import { + BrowserEditorContribution, + EditorComponentRegistry, + EditorComponentRenderMode, + IResource, + ResourceService, + WorkbenchEditorService, +} from '@opensumi/ide-editor/lib/browser/types'; + +import { AcpDebugLogView } from './acp-debug-log.view'; + +export namespace AcpDebugLogCommands { + export const OPEN_ACP_DEBUG_LOG = { + id: 'ai.native.acp.openDebugLog', + label: 'Open ACP Debug Log', + }; +} + +const COMPONENTS_ID = 'opensumi-acp-debug-log-viewer'; +export const ACP_DEBUG_LOG_SCHEME_ID = 'acp-debug-log'; + +export type IAcpDebugLogResource = IResource; + +@Domain(BrowserEditorContribution, CommandContribution) +export class AcpDebugLogContribution implements BrowserEditorContribution, CommandContribution { + @Autowired(WorkbenchEditorService) + protected readonly editorService: WorkbenchEditorService; + + registerEditorComponent(registry: EditorComponentRegistry) { + registry.registerEditorComponent({ + uid: COMPONENTS_ID, + scheme: ACP_DEBUG_LOG_SCHEME_ID, + component: AcpDebugLogView, + renderMode: EditorComponentRenderMode.ONE_PER_WORKBENCH, + }); + + registry.registerEditorComponentResolver(ACP_DEBUG_LOG_SCHEME_ID, (_, results) => { + results.push({ + type: 'component', + componentId: COMPONENTS_ID, + }); + }); + } + + registerResource(service: ResourceService) { + service.registerResourceProvider({ + scheme: ACP_DEBUG_LOG_SCHEME_ID, + provideResource: async (uri: URI): Promise => ({ + uri, + name: 'ACP Debug Log', + icon: getIcon('debug'), + }), + }); + } + + registerCommands(registry: CommandRegistry) { + registry.registerCommand(AcpDebugLogCommands.OPEN_ACP_DEBUG_LOG, { + execute: () => { + const uri = new URI().withScheme(ACP_DEBUG_LOG_SCHEME_ID); + this.editorService.open(uri, { + preview: false, + focus: true, + }); + }, + }); + } +} diff --git a/packages/ai-native/src/browser/acp/debug-log/acp-debug-log.module.less b/packages/ai-native/src/browser/acp/debug-log/acp-debug-log.module.less new file mode 100644 index 0000000000..2651764334 --- /dev/null +++ b/packages/ai-native/src/browser/acp/debug-log/acp-debug-log.module.less @@ -0,0 +1,68 @@ +.container { + height: 100%; + padding: 16px; + overflow: hidden; + display: flex; + flex-direction: column; + background: var(--editor-background); + color: var(--foreground); +} + +.header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding-bottom: 12px; + border-bottom: 1px solid var(--kt-panelTab-border); +} + +.title { + margin: 0; + font-size: 16px; + font-weight: 600; +} + +.description { + margin: 6px 0 0; + color: var(--descriptionForeground); + font-size: 12px; +} + +.actions { + display: flex; + gap: 8px; + + button { + height: 28px; + padding: 0 10px; + border: 1px solid var(--kt-button-border); + color: var(--button-foreground); + background: var(--button-background); + cursor: pointer; + } + + button:disabled { + opacity: 0.55; + cursor: not-allowed; + } +} + +.empty { + padding: 24px 0; + color: var(--descriptionForeground); + font-size: 13px; +} + +.log { + flex: 1; + margin: 12px 0 0; + padding: 12px; + overflow: auto; + background: var(--input-background); + color: var(--editor-foreground); + border: 1px solid var(--kt-panelTab-border); + font-size: 12px; + line-height: 1.5; + white-space: pre-wrap; +} diff --git a/packages/ai-native/src/browser/acp/debug-log/acp-debug-log.view.tsx b/packages/ai-native/src/browser/acp/debug-log/acp-debug-log.view.tsx new file mode 100644 index 0000000000..0ab62d4705 --- /dev/null +++ b/packages/ai-native/src/browser/acp/debug-log/acp-debug-log.view.tsx @@ -0,0 +1,84 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; + +import { useInjectable } from '@opensumi/ide-core-browser'; +import { AIBackSerivcePath, AcpDebugLogEntry, IAIBackService, IClipboardService } from '@opensumi/ide-core-common'; +import { IMessageService } from '@opensumi/ide-overlay'; + +import styles from './acp-debug-log.module.less'; + +function formatEntry(entry: AcpDebugLogEntry): string { + const timestamp = new Date(entry.timestamp).toISOString(); + const session = entry.sessionId ? ` session=${entry.sessionId}` : ''; + const payload = entry.payload ? `\n${JSON.stringify(entry.payload, null, 2)}` : ''; + return `[${timestamp}] [${entry.direction}] agent=${entry.agentId} thread=${entry.threadId}${session}\n${entry.raw}${payload}`; +} + +export const AcpDebugLogView: React.FC = () => { + const aiBackService = useInjectable(AIBackSerivcePath); + const clipboardService = useInjectable(IClipboardService); + const messageService = useInjectable(IMessageService); + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(false); + + const refresh = useCallback(async () => { + if (!aiBackService.getAcpDebugLog) { + setEntries([]); + return; + } + setLoading(true); + try { + setEntries(await aiBackService.getAcpDebugLog()); + } catch (error) { + messageService.error(error.message); + } finally { + setLoading(false); + } + }, [aiBackService, messageService]); + + const handleClear = useCallback(async () => { + if (!aiBackService.clearAcpDebugLog) { + return; + } + await aiBackService.clearAcpDebugLog(); + setEntries([]); + }, [aiBackService]); + + const renderedLog = useMemo(() => entries.map(formatEntry).join('\n\n'), [entries]); + + const handleCopyAll = useCallback(async () => { + await clipboardService.writeText(renderedLog); + }, [clipboardService, renderedLog]); + + useEffect(() => { + refresh(); + const timer = window.setInterval(refresh, 1000); + return () => window.clearInterval(timer); + }, [refresh]); + + return ( +
+
+
+

ACP Debug Log

+

Recent ACP protocol messages and stderr output.

+
+
+ + + +
+
+ {entries.length === 0 ? ( +
No ACP debug log entries yet.
+ ) : ( +
{renderedLog}
+ )} +
+ ); +}; diff --git a/packages/ai-native/src/browser/acp/index.ts b/packages/ai-native/src/browser/acp/index.ts index 78c39d5487..9877caee12 100644 --- a/packages/ai-native/src/browser/acp/index.ts +++ b/packages/ai-native/src/browser/acp/index.ts @@ -1,5 +1,37 @@ export { AcpPermissionHandler } from './permission.handler'; +export { AcpChatRelayStore } from './acp-chat-relay-store'; +export type { AcpChatRelayPutOptions, AcpChatRelayRecord } from './acp-chat-relay-store'; +export { AcpChatRelaySummaryProvider } from './acp-chat-relay-summary-provider'; +export type { + AcpChatRelaySummaryOptions, + AcpChatRelaySummaryResult, + AcpChatRelaySummarySession, +} from './acp-chat-relay-summary-provider'; 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 { createAcpChatGroup } from './webmcp-groups/acp-chat.webmcp-group'; +export { createDiagnosticsGroup } from './webmcp-groups/diagnostics.webmcp-group'; +export { createEditorGroup } from './webmcp-groups/editor.webmcp-group'; +export { createFileGroup } from './webmcp-groups/file.webmcp-group'; +export { createSearchGroup } from './webmcp-groups/search.webmcp-group'; +export { createTerminalGroup } from './webmcp-groups/terminal.webmcp-group'; +export { createWorkspaceGroup } from './webmcp-groups/workspace.webmcp-group'; +export { AcpWebMcpRpcService } from './acp-webmcp-rpc.service'; +export { getWebMcpModelContextToolDefinitions, registerWebMcpModelContextTools } from './webmcp-model-context-adapter'; +export type { + WebMcpModelContextAdapterOptions, + WebMcpModelContextToolDefinition, +} from './webmcp-model-context-adapter'; +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..73019001e7 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,69 @@ 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. + * Accepts both `acp:` and raw `` — the pending index is keyed + * by raw id (as supplied by the agent), so callers passing the ChatModel + * sessionId (prefixed) would otherwise silently miss. + */ + hasPendingForSession(sessionId: string): boolean { + const rawId = sessionId.startsWith('acp:') ? sessionId.slice(4) : sessionId; + return (this.pendingBySessionId.get(rawId)?.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..92881ec8c0 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 = @@ -286,6 +316,9 @@ const AcpPermissionDialogContainer: React.FC = () => { marginBottom: 8, backgroundColor: 'rgba(255, 0, 0, 0.2)', }} + data-testid='acp-permission-dialog' + role='dialog' + aria-label='ACP permission request' >
{ width: 'calc(100% - 16px)', }} tabIndex={0} + data-testid='acp-permission-dialog-inner' > {/* 头部:标题和关闭按钮 */}
{ alignItems: 'center', gap: 6, }} + data-testid='acp-permission-dialog-title' > { justifyContent: 'center', color: 'var(--app-secondary-foreground)', }} + aria-label='Close permission dialog' + data-testid='acp-permission-dialog-close' > @@ -376,13 +413,14 @@ const AcpPermissionDialogContainer: React.FC = () => { backgroundColor: 'var(--app-input-background)', borderRadius: 4, }} + data-testid='acp-permission-dialog-content' > {params.content}
)} {/* 选项按钮 */} -
+
{(params.options || []).map((option, index) => { const isFocused = focusedIndex === index; const buttonStyle: React.CSSProperties = { @@ -409,6 +447,10 @@ const AcpPermissionDialogContainer: React.FC = () => { style={buttonStyle} onClick={() => handleDialogSelect(option.optionId || '')} onMouseEnter={() => setFocusedIndex(index)} + aria-label={`Permission option ${option.name || option.optionId}`} + data-testid={`acp-permission-dialog-option-${index}`} + data-option-id={option.optionId} + data-option-kind={option.kind} > {/* 数字徽章 */} ; + /** + * Risk metadata is used for discovery, logging, and future policy tuning. + * It does not replace permission checks inside the concrete tool handler. + */ + riskLevel?: WebMcpToolRiskLevel; + /** + * Temporary visibility escape hatch for tools that should not enter the + * ordinary MCP tool surface while the capability set is being validated. + */ + exposedByDefault?: boolean; + /** + * Profile metadata controls the default browser-side catalog surface. + * The HTTP MCP server can request includeAllTools and apply session-level + * visibility rules for catalog enablement. + */ + profiles?: WebMcpProfile[]; + execute: (params: Record) => Promise; +} + +export interface WebMcpGroupRegistration { + name: string; + description: string; + defaultLoaded: boolean; + tools: WebMcpToolExecute[]; +} + +export function getWebMcpProfileFromSearch(search: string | undefined): WebMcpProfile | undefined { + if (!search) { + return undefined; + } + const params = new URLSearchParams(search); + return [params.get(WEBMCP_PROFILE_QUERY_PARAM), params.get(WEBMCP_PROFILE_SETTING_ID)].find(isValidWebMcpProfile); +} + +export function canUseWebMcpProfileQueryOverride(hostname: string | undefined): boolean { + return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname === '[::1]'; +} + +@Injectable() +export class WebMcpGroupRegistry { + @Autowired(PreferenceService) + private readonly preferenceService: PreferenceService; + + 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(options?: WebMcpGroupDefinitionOptions): WebMcpGroupDef[] { + const profile = this.getActiveProfile(); + return Array.from(this.groups.values()).map((g) => ({ + name: g.name, + description: g.description, + defaultLoaded: g.defaultLoaded, + profile, + tools: g.tools + // By default this registry returns the profile-sized tool surface. + // HTTP MCP catalog discovery asks for includeAllTools so it can expose + // hidden groups lazily per MCP session without changing this registry. + .filter((t) => options?.includeAllTools || this.isToolInProfile(t, profile)) + .map((t) => ({ + name: t.name, + description: t.description, + inputSchema: t.inputSchema, + riskLevel: t.riskLevel, + exposedByDefault: t.exposedByDefault, + profiles: t.profiles, + })) as WebMcpGroupDef['tools'], + })); + } + + 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, toolName: 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 tool = group.tools.find((t) => t.name === toolName); + if (!tool) { + return Promise.resolve({ + success: false, + error: 'TOOL_NOT_FOUND', + details: `Tool "${toolName}" not found in group "${groupName}"`, + }); + } + const profile = this.getActiveProfile(); + if (!canExposeWebMcpTool(tool, profile)) { + return Promise.resolve({ + success: false, + error: 'PERMISSION_DENIED', + details: `Tool "${toolName}" is not allowed in WebMCP profile "${profile}"`, + }); + } + return tool.execute(params); + } + + getDefaultGroupNames(): string[] { + return Array.from(this.groups.values()) + .filter((g) => g.defaultLoaded) + .map((g) => g.name); + } + + private getActiveProfile(): WebMcpProfile { + const profileOverride = this.getRuntimeProfileOverride(); + if (profileOverride) { + return profileOverride; + } + const profile = this.preferenceService?.get(WEBMCP_PROFILE_SETTING_ID, 'default'); + if (isValidWebMcpProfile(profile)) { + return profile; + } + return 'default'; + } + + private getRuntimeProfileOverride(): WebMcpProfile | undefined { + if (typeof window === 'undefined') { + return undefined; + } + if (!canUseWebMcpProfileQueryOverride(window.location?.hostname)) { + return undefined; + } + return getWebMcpProfileFromSearch(window.location?.search); + } + + private isToolInProfile(tool: WebMcpToolExecute, profile: WebMcpProfile): boolean { + return isWebMcpToolInProfile(tool, profile); + } +} diff --git a/packages/ai-native/src/browser/acp/webmcp-groups/acp-chat.webmcp-group.ts b/packages/ai-native/src/browser/acp/webmcp-groups/acp-chat.webmcp-group.ts new file mode 100644 index 0000000000..c7810a67f2 --- /dev/null +++ b/packages/ai-native/src/browser/acp/webmcp-groups/acp-chat.webmcp-group.ts @@ -0,0 +1,760 @@ +/** + * WebMCP group definition for ACP chat observability. + * + * This group intentionally avoids tools that send chat messages or approve + * permissions, because Claude Code is already running inside the ACP chat loop. + */ +import { Injector } from '@opensumi/di'; +import { ChatServiceToken, ILogger, uuid } from '@opensumi/ide-core-common'; +import { ChatMessageRole, IHistoryChatMessage } from '@opensumi/ide-core-common/lib/types/ai-native'; + +import { IChatInternalService, IChatMessageStructure } from '../../../common'; +import { ChatService } from '../../chat/chat.api.service'; +import { AcpChatInternalService } from '../../chat/chat.internal.service.acp'; +import { AcpChatRelayStore } from '../acp-chat-relay-store'; +import { AcpChatRelaySummaryProvider, AcpChatRelaySummarySession } from '../acp-chat-relay-summary-provider'; +import { AcpPermissionBridgeService } from '../permission-bridge.service'; +import { WebMcpGroupRegistration } from '../webmcp-group-registry'; +import { classifyError, errorResult, serviceUnavailableResult, successResult, tryGetService } from '../webmcp-utils'; + +const RELAY_PREVIEW_CHARS = 300; +const RELAY_PERMISSION_PREVIEW_CHARS = 500; +const RELAY_DIGEST_CAP = 6000; +const READ_MESSAGES_DEFAULT_MAX_MESSAGES = 10; +const READ_MESSAGES_MAX_MESSAGES_CAP = 30; +const READ_MESSAGES_DEFAULT_MAX_CHARS = 4000; +const READ_MESSAGES_MAX_CHARS_CAP = 12000; + +function stripAcpPrefix(sessionId: string | undefined): string | undefined { + return sessionId?.startsWith('acp:') ? sessionId.slice(4) : sessionId; +} + +function sameSessionId(a: string | undefined, b: string | undefined): boolean { + return stripAcpPrefix(a) === stripAcpPrefix(b); +} + +function getHistoryMessageCount(session: unknown): number { + const history = (session as { history?: { getMessages?: () => unknown[] } })?.history; + return history?.getMessages?.().length ?? 0; +} + +function getMemorySummaryCount(session: unknown): number { + const history = (session as { history?: { getMemorySummaries?: () => unknown[] } })?.history; + return history?.getMemorySummaries?.().length ?? 0; +} + +function getRequestCount(session: unknown): number { + const requests = (session as { requests?: unknown[] })?.requests; + return Array.isArray(requests) ? requests.length : 0; +} + +function getSessionCreatedAt(session: unknown): number { + const model = session as { + createdAt?: number; + history?: { getMessages?: () => Array<{ timestamp?: number; replyStartTime?: number }> }; + }; + const firstMessage = model.history?.getMessages?.()[0]; + return model.createdAt || firstMessage?.timestamp || firstMessage?.replyStartTime || 0; +} + +function sortSessionsByCreatedAtDesc(sessions: unknown[]): unknown[] { + return sessions + .map((session, index) => ({ session, index, createdAt: getSessionCreatedAt(session) })) + .sort((a, b) => { + if (a.createdAt && b.createdAt && a.createdAt !== b.createdAt) { + return b.createdAt - a.createdAt; + } + if (a.createdAt && !b.createdAt) { + return -1; + } + if (!a.createdAt && b.createdAt) { + return 1; + } + return b.index - a.index; + }) + .map(({ session }) => session); +} + +function toSessionSummary(session: unknown, permissionBridge?: AcpPermissionBridgeService | null) { + const model = session as { + sessionId?: string; + title?: string; + modelId?: string; + threadStatus?: string; + slicedMessageCount?: number; + }; + return { + sessionId: model.sessionId, + rawSessionId: stripAcpPrefix(model.sessionId), + title: model.title || '', + modelId: model.modelId, + threadStatus: model.threadStatus, + createdAt: getSessionCreatedAt(session), + requestCount: getRequestCount(session), + historyMessageCount: getHistoryMessageCount(session), + slicedMessageCount: model.slicedMessageCount ?? 0, + hasPendingPermission: model.sessionId ? permissionBridge?.hasPendingForSession(model.sessionId) ?? false : false, + }; +} + +function findSessionById(sessions: unknown[], sessionId: string): unknown | undefined { + return sessions.find((session) => sameSessionId((session as { sessionId?: string }).sessionId, sessionId)); +} + +function getSessionTitle(session: unknown): string { + return (session as { title?: string }).title || '(untitled)'; +} + +function getSessionId(session: unknown): string { + return (session as { sessionId?: string }).sessionId || ''; +} + +function toPositiveCappedNumber(value: unknown, fallback: number, cap: number): number { + return Math.min(Math.max(Number(value) || fallback, 1), cap); +} + +function truncate(value: string, maxChars: number): string { + return value.length > maxChars ? value.slice(0, maxChars) : value; +} + +function getReadableMessages(session: unknown): IHistoryChatMessage[] { + const history = (session as { history?: { getMessages?: () => IHistoryChatMessage[] } }).history; + return ( + history + ?.getMessages?.() + .filter((message) => message.role === ChatMessageRole.User || message.role === ChatMessageRole.Assistant) ?? [] + ); +} + +function formatRelayMessage(record: { + sourceSessionId: string; + sourceTitle: string; + digest: string; + digestSource: string; +}): string { + const source = record.sourceTitle || record.sourceSessionId; + return `[Forwarded from ACP session: ${source}] + +${record.digest} + +Source session id: ${record.sourceSessionId} +Digest source: ${record.digestSource}`; +} + +async function findLoadedSessionById( + chatInternalService: AcpChatInternalService, + sessionId: string, +): Promise { + let sessions = chatInternalService.getSessions(); + let session = findSessionById(sessions, sessionId); + if (!session) { + sessions = await chatInternalService.getSessionsByAcp(); + session = findSessionById(sessions, sessionId); + } + if (!session) { + return undefined; + } + if (getHistoryMessageCount(session) > 0) { + return session; + } + return (await chatInternalService.loadSessionModel(getSessionId(session))) || session; +} + +export function createAcpChatGroup(container: Injector): WebMcpGroupRegistration { + return { + name: 'acp_chat', + description: 'ACP chat session state, permission status, and safe chat UI controls', + defaultLoaded: true, + tools: [ + { + name: 'acp_chat_get_session_state', + description: + 'Get the active ACP chat session state without returning user prompts or assistant response content.', + riskLevel: 'read', + inputSchema: { + type: 'object', + properties: {}, + }, + execute: async () => { + const chatInternalService = tryGetService(container, IChatInternalService); + if (!chatInternalService) { + return serviceUnavailableResult('IChatInternalService'); + } + const permissionBridge = tryGetService(container, AcpPermissionBridgeService); + try { + const sessionModel = chatInternalService.sessionModel; + if (!sessionModel) { + return successResult({ active: false, session: null }); + } + return successResult({ + active: true, + session: toSessionSummary(sessionModel, permissionBridge), + }); + } catch (err) { + return errorResult(classifyError(err), err); + } + }, + }, + { + name: 'acp_chat_get_permission_state', + description: + 'Get ACP permission dialog counts and active session id. Does not approve, reject, or expose permission content.', + riskLevel: 'read', + inputSchema: { + type: 'object', + properties: {}, + }, + execute: async () => { + const permissionBridge = tryGetService(container, AcpPermissionBridgeService); + if (!permissionBridge) { + return serviceUnavailableResult('AcpPermissionBridgeService'); + } + try { + return successResult({ + activeDialogCount: permissionBridge.getActiveDialogCount(), + activeSessionId: permissionBridge.getActiveSession(), + pendingCountExcludingActive: permissionBridge.getPendingCountExcludingActive(), + }); + } catch (err) { + return errorResult(classifyError(err), err); + } + }, + }, + { + name: 'acp_chat_show_chat_view', + description: 'Show the ACP chat view panel in the IDE.', + riskLevel: 'ui', + inputSchema: { + type: 'object', + properties: {}, + }, + execute: async () => { + const chatService = tryGetService(container, ChatServiceToken); + if (!chatService) { + return serviceUnavailableResult('ChatService'); + } + try { + chatService.showChatView(); + return successResult({ shown: true }); + } catch (err) { + return errorResult(classifyError(err), err); + } + }, + }, + { + name: 'acp_chat_list_sessions', + description: + 'List ACP chat sessions newest first as metadata only. Does not return prompts, responses, or tool-call contents.', + riskLevel: 'read', + profiles: ['interactive', 'full'], + inputSchema: { + type: 'object', + properties: {}, + }, + execute: async () => { + const chatInternalService = tryGetService(container, IChatInternalService); + if (!chatInternalService) { + return serviceUnavailableResult('IChatInternalService'); + } + const permissionBridge = tryGetService(container, AcpPermissionBridgeService); + try { + const sessions = await chatInternalService.getSessionsByAcp(); + const sortedSessions = sortSessionsByCreatedAtDesc(sessions); + return successResult({ + sessions: sortedSessions.map((session) => toSessionSummary(session, permissionBridge)), + total: sessions.length, + }); + } catch (err) { + return errorResult(classifyError(err), err); + } + }, + }, + { + name: 'acp_chat_get_available_commands', + description: 'Get available ACP slash commands for the active chat session.', + riskLevel: 'read', + profiles: ['interactive', 'full'], + inputSchema: { + type: 'object', + properties: {}, + }, + execute: async () => { + const chatInternalService = tryGetService(container, IChatInternalService); + if (!chatInternalService) { + return serviceUnavailableResult('IChatInternalService'); + } + try { + const commands = chatInternalService.getAvailableCommands(); + return successResult({ + commands: commands.map((command) => ({ + name: command.name, + description: command.description || '', + })), + total: commands.length, + }); + } catch (err) { + return errorResult(classifyError(err), err); + } + }, + }, + { + name: 'acp_chat_prepare_session_digest', + description: + 'Prepare a bounded background digest for another ACP chat session. Returns only digest metadata and a short preview; the full digest stays in the browser relay store.', + riskLevel: 'read', + profiles: ['interactive', 'full'], + inputSchema: { + type: 'object', + properties: { + sourceSessionId: { + type: 'string', + description: 'ACP session id to summarize. Accepts either acp: or raw .', + }, + maxSourceChars: { + type: 'number', + description: 'Maximum source characters used by the background summarizer. Default 12000, cap 30000.', + }, + maxDigestChars: { + type: 'number', + description: 'Maximum digest characters stored for relay. Default 2000, cap 6000.', + }, + }, + required: ['sourceSessionId'], + additionalProperties: false, + }, + execute: async (params: Record) => { + const sourceSessionId = typeof params.sourceSessionId === 'string' ? params.sourceSessionId : ''; + if (!sourceSessionId) { + return errorResult('INVALID_INPUT', new Error('sourceSessionId is required')); + } + + const chatInternalService = tryGetService(container, IChatInternalService); + if (!chatInternalService) { + return serviceUnavailableResult('IChatInternalService'); + } + const summaryProvider = tryGetService(container, AcpChatRelaySummaryProvider); + if (!summaryProvider) { + return serviceUnavailableResult('AcpChatRelaySummaryProvider'); + } + const relayStore = tryGetService(container, AcpChatRelayStore); + if (!relayStore) { + return serviceUnavailableResult('AcpChatRelayStore'); + } + const logger = tryGetService(container, ILogger); + + try { + const startedAt = Date.now(); + logger?.log?.( + `[WebMCP][acp_chat] prepareSessionDigest start — requestedSourceSessionId=${stripAcpPrefix( + sourceSessionId, + )}, maxSourceChars=${params.maxSourceChars ?? 'default'}, maxDigestChars=${ + params.maxDigestChars ?? 'default' + }`, + ); + + const sourceSession = await findLoadedSessionById(chatInternalService, sourceSessionId); + if (!sourceSession) { + logger?.warn?.( + `[WebMCP][acp_chat] prepareSessionDigest miss — requestedSourceSessionId=${stripAcpPrefix( + sourceSessionId, + )}, reason=session_not_found, durationMs=${Date.now() - startedAt}`, + ); + return errorResult('FILE_NOT_FOUND', new Error(`ACP session "${sourceSessionId}" not found`)); + } + + const summary = await summaryProvider.prepareSessionDigest(sourceSession as AcpChatRelaySummarySession, { + maxSourceChars: params.maxSourceChars as number | undefined, + maxDigestChars: params.maxDigestChars as number | undefined, + }); + const sourceId = getSessionId(sourceSession); + const record = relayStore.put({ + sourceSessionId: sourceId, + sourceTitle: getSessionTitle(sourceSession), + digestSource: summary.digestSource, + digest: summary.digest, + sourceChars: summary.sourceChars, + digestChars: summary.digestChars, + sourceTruncated: summary.sourceTruncated, + }); + + logger?.log?.( + `[WebMCP][acp_chat] prepareSessionDigest — sourceSessionId=${stripAcpPrefix(sourceId)}, digestId=${ + record.digestId + }, digestSource=${summary.digestSource}, historyMessages=${getHistoryMessageCount( + sourceSession, + )}, memorySummaries=${getMemorySummaryCount(sourceSession)}, sourceChars=${ + summary.sourceChars + }, digestChars=${summary.digestChars}, sourceTruncated=${summary.sourceTruncated}, expiresInMs=${ + record.expiresAt - record.createdAt + }, durationMs=${Date.now() - startedAt}`, + ); + + return successResult({ + digestId: record.digestId, + sourceSessionId: sourceId, + sourceTitle: record.sourceTitle, + digestSource: record.digestSource, + preview: truncate(record.digest, RELAY_PREVIEW_CHARS), + digestChars: record.digestChars, + sourceChars: record.sourceChars, + sourceTruncated: record.sourceTruncated, + expiresAt: record.expiresAt, + }); + } catch (err) { + logger?.warn?.( + `[WebMCP][acp_chat] prepareSessionDigest error — requestedSourceSessionId=${stripAcpPrefix( + sourceSessionId, + )}, errorName=${err instanceof Error ? err.name : typeof err}`, + ); + return errorResult(classifyError(err), err); + } + }, + }, + { + name: 'acp_chat_post_prepared_relay', + description: + 'Post a previously prepared ACP chat digest to a target ACP session after explicit user permission.', + riskLevel: 'write', + profiles: ['full'], + inputSchema: { + type: 'object', + properties: { + digestId: { + type: 'string', + description: 'Digest id returned by acp_chat_prepare_session_digest.', + }, + targetSessionId: { + type: 'string', + description: 'ACP session id to receive the relay message. Accepts either acp: or raw .', + }, + }, + required: ['digestId', 'targetSessionId'], + additionalProperties: false, + }, + execute: async (params: Record) => { + const digestId = typeof params.digestId === 'string' ? params.digestId : ''; + const targetSessionId = typeof params.targetSessionId === 'string' ? params.targetSessionId : ''; + if (!digestId || !targetSessionId) { + return errorResult('INVALID_INPUT', new Error('digestId and targetSessionId are required')); + } + + const chatInternalService = tryGetService(container, IChatInternalService); + if (!chatInternalService) { + return serviceUnavailableResult('IChatInternalService'); + } + const chatService = tryGetService(container, ChatServiceToken); + if (!chatService) { + return serviceUnavailableResult('ChatService'); + } + const permissionBridge = tryGetService(container, AcpPermissionBridgeService); + if (!permissionBridge) { + return serviceUnavailableResult('AcpPermissionBridgeService'); + } + const relayStore = tryGetService(container, AcpChatRelayStore); + if (!relayStore) { + return serviceUnavailableResult('AcpChatRelayStore'); + } + const logger = tryGetService(container, ILogger); + + try { + const startedAt = Date.now(); + logger?.log?.( + `[WebMCP][acp_chat] postPreparedRelay start — digestId=${digestId}, requestedTargetSessionId=${stripAcpPrefix( + targetSessionId, + )}`, + ); + + const record = relayStore.get(digestId); + if (!record) { + logger?.warn?.( + `[WebMCP][acp_chat] postPreparedRelay miss — digestId=${digestId}, requestedTargetSessionId=${stripAcpPrefix( + targetSessionId, + )}, reason=digest_not_found_or_expired, durationMs=${Date.now() - startedAt}`, + ); + return errorResult('FILE_NOT_FOUND', new Error(`Relay digest "${digestId}" not found or expired`)); + } + if (!record.digest) { + logger?.warn?.( + `[WebMCP][acp_chat] postPreparedRelay miss — digestId=${digestId}, sourceSessionId=${stripAcpPrefix( + record.sourceSessionId, + )}, reason=empty_digest, durationMs=${Date.now() - startedAt}`, + ); + return errorResult('INVALID_INPUT', new Error(`Relay digest "${digestId}" is empty`)); + } + + let sessions = chatInternalService.getSessions(); + let targetSession = findSessionById(sessions, targetSessionId); + if (!targetSession) { + sessions = await chatInternalService.getSessionsByAcp(); + targetSession = findSessionById(sessions, targetSessionId); + } + if (!targetSession) { + logger?.warn?.( + `[WebMCP][acp_chat] postPreparedRelay miss — digestId=${digestId}, sourceSessionId=${stripAcpPrefix( + record.sourceSessionId, + )}, requestedTargetSessionId=${stripAcpPrefix( + targetSessionId, + )}, reason=target_session_not_found, durationMs=${Date.now() - startedAt}`, + ); + return errorResult('FILE_NOT_FOUND', new Error(`Target ACP session "${targetSessionId}" not found`)); + } + + const originalSessionId = chatInternalService.sessionModel?.sessionId; + const targetId = getSessionId(targetSession); + const willSwitchSession = !sameSessionId(originalSessionId, targetId); + const relayMessage = formatRelayMessage({ + sourceSessionId: record.sourceSessionId, + sourceTitle: record.sourceTitle, + digest: truncate(record.digest, RELAY_DIGEST_CAP), + digestSource: record.digestSource, + }); + const permissionRequestId = `${stripAcpPrefix(originalSessionId) || 'acp_chat'}:relay:${uuid(8)}`; + const permissionStartedAt = Date.now(); + + logger?.log?.( + `[WebMCP][acp_chat] postPreparedRelay permission request — digestId=${digestId}, requestId=${permissionRequestId}, sourceSessionId=${stripAcpPrefix( + record.sourceSessionId, + )}, targetSessionId=${stripAcpPrefix(targetId)}, digestChars=${ + record.digestChars + }, switchedSession=${willSwitchSession}`, + ); + const decision = await permissionBridge.showPermissionDialog({ + requestId: permissionRequestId, + sessionId: stripAcpPrefix(originalSessionId) || stripAcpPrefix(targetId) || 'acp_chat', + title: 'Forward ACP chat digest', + kind: 'write', + content: [ + `Source: ${record.sourceTitle || record.sourceSessionId}`, + `Target: ${getSessionTitle(targetSession)} (${targetId})`, + `Digest chars: ${record.digestChars}`, + `Temporary session switch: ${willSwitchSession ? 'yes' : 'no'}`, + '', + truncate(record.digest, RELAY_PERMISSION_PREVIEW_CHARS), + ].join('\n'), + options: [ + { optionId: 'allow_once', name: 'Allow Once', kind: 'allow_once' }, + { optionId: 'reject', name: 'Reject', kind: 'reject_once' }, + ], + timeout: 60000, + }); + logger?.log?.( + `[WebMCP][acp_chat] postPreparedRelay permission result — digestId=${digestId}, requestId=${permissionRequestId}, decision=${ + decision.type + }, optionId=${'optionId' in decision ? decision.optionId : ''}, durationMs=${ + Date.now() - permissionStartedAt + }`, + ); + + if (decision.type !== 'allow') { + logger?.warn?.( + `[WebMCP][acp_chat] postPreparedRelay denied — digestId=${digestId}, requestId=${permissionRequestId}, decision=${ + decision.type + }, durationMs=${Date.now() - startedAt}`, + ); + return { + success: false, + error: 'PERMISSION_DENIED', + details: `Relay rejected or cancelled: ${decision.type}`, + }; + } + + try { + if (willSwitchSession) { + logger?.log?.( + `[WebMCP][acp_chat] postPreparedRelay session switch — digestId=${digestId}, fromSessionId=${stripAcpPrefix( + originalSessionId, + )}, toSessionId=${stripAcpPrefix(targetId)}`, + ); + await chatInternalService.activateSession(targetId); + } + const messageData: IChatMessageStructure = { + message: relayMessage, + immediate: true, + }; + chatService.sendMessage(messageData); + logger?.log?.( + `[WebMCP][acp_chat] postPreparedRelay message sent — digestId=${digestId}, targetSessionId=${stripAcpPrefix( + targetId, + )}, messageChars=${relayMessage.length}`, + ); + relayStore.delete(digestId); + } finally { + if (willSwitchSession && originalSessionId) { + await chatInternalService.activateSession(originalSessionId); + logger?.log?.( + `[WebMCP][acp_chat] postPreparedRelay session restored — digestId=${digestId}, restoredSessionId=${stripAcpPrefix( + originalSessionId, + )}`, + ); + } + } + + logger?.log?.( + `[WebMCP][acp_chat] postPreparedRelay — digestId=${digestId}, sourceSessionId=${stripAcpPrefix( + record.sourceSessionId, + )}, targetSessionId=${stripAcpPrefix(targetId)}, digestSource=${record.digestSource}, digestChars=${ + record.digestChars + }, switchedSession=${willSwitchSession}, durationMs=${Date.now() - startedAt}`, + ); + + return successResult({ + posted: true, + digestId, + sourceSessionId: record.sourceSessionId, + targetSessionId: targetId, + digestChars: record.digestChars, + switchedSession: willSwitchSession, + }); + } catch (err) { + logger?.warn?.( + `[WebMCP][acp_chat] postPreparedRelay error — digestId=${digestId}, requestedTargetSessionId=${stripAcpPrefix( + targetSessionId, + )}, errorName=${err instanceof Error ? err.name : typeof err}`, + ); + return errorResult(classifyError(err), err); + } + }, + }, + { + name: 'acp_chat_read_session_messages', + description: + 'Read bounded recent user/assistant message previews from an ACP session. Full-profile debug fallback only.', + riskLevel: 'read', + profiles: ['full'], + inputSchema: { + type: 'object', + properties: { + sessionId: { + type: 'string', + description: 'ACP session id to read. Accepts either acp: or raw .', + }, + maxMessages: { + type: 'number', + description: 'Maximum recent messages to return. Default 10, cap 30.', + }, + maxChars: { + type: 'number', + description: 'Maximum total preview characters. Default 4000, cap 12000.', + }, + sinceRequestId: { + type: 'string', + description: 'Optional request id lower bound. Messages before this request id are skipped.', + }, + }, + required: ['sessionId'], + additionalProperties: false, + }, + execute: async (params: Record) => { + const sessionId = typeof params.sessionId === 'string' ? params.sessionId : ''; + if (!sessionId) { + return errorResult('INVALID_INPUT', new Error('sessionId is required')); + } + const chatInternalService = tryGetService(container, IChatInternalService); + if (!chatInternalService) { + return serviceUnavailableResult('IChatInternalService'); + } + + try { + const session = await findLoadedSessionById(chatInternalService, sessionId); + if (!session) { + return errorResult('FILE_NOT_FOUND', new Error(`ACP session "${sessionId}" not found`)); + } + + const maxMessages = toPositiveCappedNumber( + params.maxMessages, + READ_MESSAGES_DEFAULT_MAX_MESSAGES, + READ_MESSAGES_MAX_MESSAGES_CAP, + ); + const maxChars = toPositiveCappedNumber( + params.maxChars, + READ_MESSAGES_DEFAULT_MAX_CHARS, + READ_MESSAGES_MAX_CHARS_CAP, + ); + const sinceRequestId = typeof params.sinceRequestId === 'string' ? params.sinceRequestId : undefined; + let sourceMessages = getReadableMessages(session); + if (sinceRequestId) { + const index = sourceMessages.findIndex((message) => message.requestId === sinceRequestId); + if (index >= 0) { + sourceMessages = sourceMessages.slice(index + 1); + } + } + + let usedChars = 0; + let truncated = sourceMessages.length > maxMessages; + const messages: Array<{ + role: 'user' | 'assistant'; + contentPreview: string; + chars: number; + truncated: boolean; + }> = []; + const selectedMessages = sourceMessages.slice(-maxMessages); + for (const message of selectedMessages) { + const remaining = Math.max(maxChars - usedChars, 0); + if (remaining <= 0) { + truncated = true; + break; + } + const contentPreview = truncate(message.content || '', remaining); + usedChars += contentPreview.length; + const messageTruncated = contentPreview.length < (message.content || '').length; + if (messageTruncated) { + truncated = true; + } + messages.push({ + role: message.role === ChatMessageRole.User ? 'user' : 'assistant', + contentPreview, + chars: (message.content || '').length, + truncated: messageTruncated, + }); + } + + return successResult({ + sessionId: getSessionId(session), + title: getSessionTitle(session), + requestCount: getRequestCount(session), + historyMessageCount: getHistoryMessageCount(session), + messages, + truncated, + }); + } catch (err) { + return errorResult(classifyError(err), err); + } + }, + }, + { + name: 'acp_chat_set_session_mode', + description: + 'Switch the active ACP session mode. This changes agent behavior and is only available in the full WebMCP profile.', + riskLevel: 'write', + profiles: ['full'], + inputSchema: { + type: 'object', + properties: { + modeId: { + type: 'string', + description: 'ACP session mode id, for example agent or chat.', + }, + }, + required: ['modeId'], + additionalProperties: false, + }, + execute: async (params: Record) => { + const modeId = typeof params.modeId === 'string' ? params.modeId : ''; + if (!modeId) { + return errorResult('INVALID_INPUT', new Error('modeId is required')); + } + const chatInternalService = tryGetService(container, IChatInternalService); + if (!chatInternalService) { + return serviceUnavailableResult('IChatInternalService'); + } + try { + await chatInternalService.setSessionMode(modeId); + return successResult({ modeId }); + } catch (err) { + return errorResult(classifyError(err), err); + } + }, + }, + ], + }; +} diff --git a/packages/ai-native/src/browser/acp/webmcp-groups/diagnostics.webmcp-group.ts b/packages/ai-native/src/browser/acp/webmcp-groups/diagnostics.webmcp-group.ts new file mode 100644 index 0000000000..9ce0ee2808 --- /dev/null +++ b/packages/ai-native/src/browser/acp/webmcp-groups/diagnostics.webmcp-group.ts @@ -0,0 +1,231 @@ +/** + * WebMCP group definition for IDE diagnostics. + */ +import { Injector } from '@opensumi/di'; +import { MarkerSeverity, URI } from '@opensumi/ide-core-common'; +import { WorkbenchEditorService } from '@opensumi/ide-editor'; +import { IMarkerService } from '@opensumi/ide-markers'; +import { IWorkspaceService } from '@opensumi/ide-workspace'; + +import { WebMcpGroupRegistration } from '../webmcp-group-registry'; +import { classifyError, errorResult, serviceUnavailableResult, successResult, tryGetService } from '../webmcp-utils'; + +const DEFAULT_DIAGNOSTIC_RESULTS = 100; +const MAX_DIAGNOSTIC_RESULTS = 500; + +interface SafeDiagnosticStats { + errors: number; + warnings: number; + infos: number; + unknowns: number; +} + +function toPositiveCappedNumber(value: unknown, fallback: number, cap: number): number { + return Math.min(Math.max(Number(value) || fallback, 1), cap); +} + +function severityMask(value: unknown): number | undefined { + if (typeof value !== 'string' || value === 'all') { + return undefined; + } + const normalized = value.toLowerCase(); + if (normalized === 'error') { + return MarkerSeverity.Error; + } + if (normalized === 'warning') { + return MarkerSeverity.Warning; + } + if (normalized === 'info') { + return MarkerSeverity.Info; + } + if (normalized === 'hint') { + return MarkerSeverity.Hint; + } + return undefined; +} + +function severityName(severity: MarkerSeverity): string { + if (severity === MarkerSeverity.Error) { + return 'error'; + } + if (severity === MarkerSeverity.Warning) { + return 'warning'; + } + if (severity === MarkerSeverity.Info) { + return 'info'; + } + return 'hint'; +} + +function toSafeDiagnosticStats(stats: Partial): SafeDiagnosticStats { + return { + errors: Number(stats.errors) || 0, + warnings: Number(stats.warnings) || 0, + infos: Number(stats.infos) || 0, + unknowns: Number(stats.unknowns) || 0, + }; +} + +function resolveResourceUri(workspaceService: IWorkspaceService | null, pathOrUri: string): string { + if (pathOrUri.startsWith('file://')) { + return pathOrUri; + } + if (pathOrUri.startsWith('/')) { + return URI.file(pathOrUri).toString(); + } + const root = workspaceService?.tryGetRoots()[0]; + if (!root) { + return URI.file(pathOrUri).toString(); + } + const rootPath = URI.parse(root.uri).codeUri.fsPath; + return URI.file(`${rootPath}/${pathOrUri}`.replace(/\/+/g, '/')).toString(); +} + +export function createDiagnosticsGroup(container: Injector): WebMcpGroupRegistration { + return { + name: 'diagnostics', + description: 'IDE diagnostics and problem navigation', + defaultLoaded: true, + tools: [ + { + name: 'diagnostics_list', + description: + 'List current IDE diagnostics. Use this after edits or validation commands to inspect errors and warnings.', + riskLevel: 'read', + inputSchema: { + type: 'object', + properties: { + path: { + type: 'string', + description: 'Optional file path or file URI to filter diagnostics.', + }, + severity: { + type: 'string', + enum: ['all', 'error', 'warning', 'info', 'hint'], + description: 'Optional severity filter. Defaults to all.', + }, + maxResults: { + type: 'number', + description: 'Maximum diagnostics to return. Defaults to 100, capped at 500.', + }, + }, + }, + execute: async (params: Record) => { + const markerService = tryGetService(container, IMarkerService); + if (!markerService) { + return serviceUnavailableResult('IMarkerService'); + } + const workspaceService = tryGetService(container, IWorkspaceService); + try { + const maxResults = toPositiveCappedNumber( + params.maxResults, + DEFAULT_DIAGNOSTIC_RESULTS, + MAX_DIAGNOSTIC_RESULTS, + ); + const resource = + typeof params.path === 'string' && params.path + ? resolveResourceUri(workspaceService, params.path) + : undefined; + const markers = markerService.getManager().getMarkers({ + resource, + severities: severityMask(params.severity), + take: maxResults, + }); + const diagnostics = markers.map((marker) => ({ + type: marker.type, + uri: marker.resource, + path: URI.parse(marker.resource).codeUri.fsPath, + severity: severityName(marker.severity), + message: marker.message, + source: marker.source, + code: marker.code, + startLine: marker.startLineNumber, + startColumn: marker.startColumn, + endLine: marker.endLineNumber, + endColumn: marker.endColumn, + })); + return successResult({ + diagnostics, + stats: toSafeDiagnosticStats(markerService.getManager().getStats()), + total: diagnostics.length, + truncated: markers.length >= maxResults, + }); + } catch (err) { + return errorResult(classifyError(err), err); + } + }, + }, + { + name: 'diagnostics_get_stats', + description: 'Get diagnostic counts by severity for the current workspace.', + riskLevel: 'read', + inputSchema: { + type: 'object', + properties: {}, + }, + execute: async () => { + const markerService = tryGetService(container, IMarkerService); + if (!markerService) { + return serviceUnavailableResult('IMarkerService'); + } + try { + return successResult(toSafeDiagnosticStats(markerService.getManager().getStats())); + } catch (err) { + return errorResult(classifyError(err), err); + } + }, + }, + { + name: 'diagnostics_open', + description: 'Open a file and reveal the given diagnostic location.', + riskLevel: 'ui', + inputSchema: { + type: 'object', + properties: { + path: { + type: 'string', + description: 'File path or file URI to open.', + }, + line: { + type: 'number', + description: 'Line number to reveal, 1-based.', + }, + column: { + type: 'number', + description: 'Column number to reveal, 1-based. Defaults to 1.', + }, + }, + required: ['path', 'line'], + }, + execute: async (params: Record) => { + const path = typeof params.path === 'string' ? params.path : ''; + const line = Number(params.line); + if (!path || !line || line < 1) { + return errorResult('INVALID_INPUT', new Error('path and positive line are required')); + } + const workspaceService = tryGetService(container, IWorkspaceService); + const editorService = tryGetService(container, WorkbenchEditorService); + if (!editorService) { + return serviceUnavailableResult('WorkbenchEditorService'); + } + try { + const uri = URI.parse(resolveResourceUri(workspaceService, path)); + const column = Math.max(Number(params.column) || 1, 1); + await editorService.open(uri, { + range: { + startLineNumber: line, + startColumn: column, + endLineNumber: line, + endColumn: column, + }, + revealRangeInCenter: true, + }); + return successResult({ path: uri.codeUri.fsPath, line, column, opened: true }); + } catch (err) { + return errorResult(classifyError(err), err); + } + }, + }, + ], + }; +} 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..2e6a55bba2 --- /dev/null +++ b/packages/ai-native/src/browser/acp/webmcp-groups/editor.webmcp-group.ts @@ -0,0 +1,888 @@ +/** + * 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: editor_{action} + */ +import { Injector } from '@opensumi/di'; +import { AppConfig } from '@opensumi/ide-core-browser'; +import { CommandService, URI } from '@opensumi/ide-core-common'; +import { IEditor, IEditorDocumentModel, IResourceOpenOptions, WorkbenchEditorService } from '@opensumi/ide-editor'; +import { IEditorDocumentModelService } from '@opensumi/ide-editor/lib/browser/doc-model/types'; +import { IFileServiceClient } from '@opensumi/ide-file-service'; + +import { WebMcpGroupRegistration } from '../webmcp-group-registry'; +import { classifyError, errorResult, serviceUnavailableResult, successResult, tryGetService } from '../webmcp-utils'; + +import { + resolveWorkspaceFilePath, + validateWorkspacePathAccess, + validateWritableWorkspaceTarget, +} from './file-workspace-path'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +interface ActiveEditorInfo { + path: string | null; + uri: 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, + uri: uri ? uri.toString() : null, + selection: primarySelection + ? { + startLine: primarySelection.selectionStartLineNumber, + startCol: primarySelection.selectionStartColumn, + endLine: primarySelection.positionLineNumber, + endCol: primarySelection.positionColumn, + } + : null, + }; +} + +function resolveEditorUri(container: Injector, pathOrUri: string): URI { + if (pathOrUri.startsWith('file://')) { + return URI.parse(pathOrUri); + } + if (pathOrUri.startsWith('/')) { + return URI.file(pathOrUri); + } + const appConfig = tryGetService(container, AppConfig); + const workspaceDir = appConfig?.workspaceDir; + return URI.file(workspaceDir ? `${workspaceDir}/${pathOrUri}`.replace(/\/+/g, '/') : pathOrUri); +} + +function invalidPathResult(message: string) { + return errorResult('INVALID_INPUT', new Error(message)); +} + +async function resolveWorkspaceEditorUri( + container: Injector, + filePath: string, + access: 'read' | 'write', +): Promise< + | { + ok: true; + uri: URI; + absolutePath: string; + } + | { + ok: false; + result: ReturnType; + } +> { + const appConfig = tryGetService(container, AppConfig); + if (!appConfig || !appConfig.workspaceDir) { + return { ok: false, result: serviceUnavailableResult('AppConfig') }; + } + const fileService = tryGetService(container, IFileServiceClient); + if (!fileService) { + return { ok: false, result: serviceUnavailableResult('IFileServiceClient') }; + } + + const resolved = resolveWorkspaceFilePath(appConfig.workspaceDir, filePath); + if (!resolved.ok) { + return { ok: false, result: invalidPathResult(resolved.message) }; + } + const validation = + access === 'write' + ? await validateWritableWorkspaceTarget(fileService, appConfig.workspaceDir, resolved.value) + : await validateWorkspacePathAccess(fileService, appConfig.workspaceDir, resolved.value); + if (!validation.ok) { + return { ok: false, result: invalidPathResult(validation.message) }; + } + + return { + ok: true, + uri: URI.file(resolved.value.absolutePath), + absolutePath: resolved.value.absolutePath, + }; +} + +function toPositiveCappedNumber(value: unknown, fallback: number, cap: number): number { + return Math.min(Math.max(Number(value) || fallback, 1), cap); +} + +async function withDocumentModel( + container: Injector, + uri: URI, + fn: (model: IEditorDocumentModel) => T | Promise, +): Promise { + const documentModelService = tryGetService(container, IEditorDocumentModelService); + if (!documentModelService) { + return null; + } + const existingRef = documentModelService.getModelReference(uri, 'webmcp'); + if (existingRef) { + try { + return await fn(existingRef.instance); + } finally { + existingRef.dispose(); + } + } + const ref = await documentModelService.createModelReference(uri, 'webmcp'); + try { + return await fn(ref.instance); + } finally { + ref.dispose(); + } +} + +function createSimpleDiff(original: string, modified: string, maxLines: number): { diff: string; truncated: boolean } { + const originalLines = original.split(/\r?\n/); + const modifiedLines = modified.split(/\r?\n/); + let prefix = 0; + while ( + prefix < originalLines.length && + prefix < modifiedLines.length && + originalLines[prefix] === modifiedLines[prefix] + ) { + prefix++; + } + let suffix = 0; + while ( + suffix + prefix < originalLines.length && + suffix + prefix < modifiedLines.length && + originalLines[originalLines.length - 1 - suffix] === modifiedLines[modifiedLines.length - 1 - suffix] + ) { + suffix++; + } + const removed = originalLines.slice(prefix, originalLines.length - suffix); + const added = modifiedLines.slice(prefix, modifiedLines.length - suffix); + const lines = [ + `@@ -${prefix + 1},${removed.length} +${prefix + 1},${added.length} @@`, + ...removed.map((line) => `-${line}`), + ...added.map((line) => `+${line}`), + ]; + return { + diff: lines.slice(0, maxLines).join('\n'), + truncated: lines.length > maxLines, + }; +} + +// --------------------------------------------------------------------------- +// Group definition +// --------------------------------------------------------------------------- + +export function createEditorGroup(container: Injector): WebMcpGroupRegistration { + return { + name: 'editor', + description: '编辑器操作(打开、关闭、跳转、格式化等)', + defaultLoaded: true, + tools: [ + // ----- editor_open ----- + { + name: '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.', + riskLevel: 'ui', + 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); + } + }, + }, + + // ----- editor_close ----- + { + name: 'editor_close', + description: 'Close the editor tab for the given file path.', + riskLevel: 'ui', + profiles: ['interactive', 'full'], + 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); + } + }, + }, + + // ----- editor_get_active ----- + { + name: 'editor_get_active', + description: 'Get information about the currently active editor, including file path and selection range.', + riskLevel: 'read', + 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); + } + }, + }, + + // ----- editor_list_open_files ----- + { + name: 'editor_list_open_files', + description: 'List files currently opened in editor groups, including dirty and active state.', + riskLevel: 'read', + inputSchema: { + type: 'object', + properties: {}, + }, + execute: async () => { + const editorService = tryGetService(container, WorkbenchEditorService); + if (!editorService) { + return serviceUnavailableResult('WorkbenchEditorService'); + } + const documentModelService = tryGetService( + container, + IEditorDocumentModelService, + ); + try { + const activeUri = editorService.currentEditor?.currentUri?.toString(); + const files = editorService.editorGroups.flatMap((group, groupIndex) => + group.resources.map((resource) => { + const ref = documentModelService?.getModelReference(resource.uri, 'webmcp'); + try { + const model = ref?.instance; + return { + uri: resource.uri.toString(), + path: resource.uri.codeUri.fsPath, + name: resource.name, + groupIndex, + active: resource.uri.toString() === activeUri, + dirty: Boolean(model?.dirty), + languageId: model?.languageId, + }; + } finally { + ref?.dispose(); + } + }), + ); + return successResult({ files, total: files.length }); + } catch (err) { + return errorResult(classifyError(err), err); + } + }, + }, + + // ----- editor_get_selection ----- + { + name: 'editor_get_selection', + description: 'Get the active editor selection range and selected text.', + riskLevel: 'read', + inputSchema: { + type: 'object', + properties: { + maxChars: { + type: 'number', + description: 'Maximum selected characters to return. Defaults to 20000, capped at 100000.', + }, + }, + }, + execute: async (params: Record) => { + const editorService = tryGetService(container, WorkbenchEditorService); + if (!editorService) { + return serviceUnavailableResult('WorkbenchEditorService'); + } + try { + const editor = editorService.currentEditor; + const uri = editor?.currentUri; + const selection = editor?.getSelections()?.[0]; + if (!editor || !uri || !selection) { + return successResult({ active: false, selection: null, text: '' }); + } + const maxChars = toPositiveCappedNumber(params.maxChars, 20_000, 100_000); + const text = + editor.currentDocumentModel?.getText({ + startLineNumber: selection.selectionStartLineNumber, + startColumn: selection.selectionStartColumn, + endLineNumber: selection.positionLineNumber, + endColumn: selection.positionColumn, + }) ?? ''; + return successResult({ + active: true, + uri: uri.toString(), + path: uri.codeUri.fsPath, + selection: { + startLine: selection.selectionStartLineNumber, + startColumn: selection.selectionStartColumn, + endLine: selection.positionLineNumber, + endColumn: selection.positionColumn, + }, + text: text.slice(0, maxChars), + truncated: text.length > maxChars, + }); + } catch (err) { + return errorResult(classifyError(err), err); + } + }, + }, + + // ----- editor_read_buffer ----- + { + name: 'editor_read_buffer', + description: 'Read an editor buffer, including unsaved content. Defaults to the active editor.', + riskLevel: 'read', + inputSchema: { + type: 'object', + properties: { + path: { + type: 'string', + description: 'Optional file path or file URI. Defaults to the active editor.', + }, + maxChars: { + type: 'number', + description: 'Maximum characters to return. Defaults to 100000, capped at 500000.', + }, + }, + }, + execute: async (params: Record) => { + const editorService = tryGetService(container, WorkbenchEditorService); + if (!editorService) { + return serviceUnavailableResult('WorkbenchEditorService'); + } + try { + const uri = + typeof params.path === 'string' && params.path + ? resolveEditorUri(container, params.path) + : editorService.currentEditor?.currentUri; + if (!uri) { + return errorResult('INVALID_INPUT', new Error('path is required when no active editor exists')); + } + const maxChars = toPositiveCappedNumber(params.maxChars, 100_000, 500_000); + const data = await withDocumentModel(container, uri, (model) => { + const text = model.getText(); + return { + uri: uri.toString(), + path: uri.codeUri.fsPath, + languageId: model.languageId, + dirty: model.dirty, + text: text.slice(0, maxChars), + size: text.length, + truncated: text.length > maxChars, + }; + }); + if (!data) { + return serviceUnavailableResult('IEditorDocumentModelService'); + } + return successResult(data); + } catch (err) { + return errorResult(classifyError(err), err); + } + }, + }, + + // ----- editor_read_range_from_buffer ----- + { + name: 'editor_read_range_from_buffer', + description: 'Read a line range from an editor buffer, including unsaved content.', + riskLevel: 'read', + inputSchema: { + type: 'object', + properties: { + path: { + type: 'string', + description: 'Optional file path or file URI. Defaults to the active editor.', + }, + startLine: { + type: 'number', + description: 'Start line, 1-based.', + }, + endLine: { + type: 'number', + description: 'End line, 1-based. Defaults to startLine.', + }, + maxChars: { + type: 'number', + description: 'Maximum characters to return. Defaults to 50000, capped at 200000.', + }, + }, + required: ['startLine'], + }, + execute: async (params: Record) => { + const startLine = Number(params.startLine); + const endLine = Number(params.endLine) || startLine; + if (!startLine || startLine < 1 || endLine < startLine) { + return errorResult('INVALID_INPUT', new Error('valid startLine and endLine are required')); + } + const editorService = tryGetService(container, WorkbenchEditorService); + if (!editorService) { + return serviceUnavailableResult('WorkbenchEditorService'); + } + try { + const uri = + typeof params.path === 'string' && params.path + ? resolveEditorUri(container, params.path) + : editorService.currentEditor?.currentUri; + if (!uri) { + return errorResult('INVALID_INPUT', new Error('path is required when no active editor exists')); + } + const maxChars = toPositiveCappedNumber(params.maxChars, 50_000, 200_000); + const data = await withDocumentModel(container, uri, (model) => { + const lineCount = model.getMonacoModel().getLineCount(); + if (startLine > lineCount) { + return { + uri: uri.toString(), + path: uri.codeUri.fsPath, + startLine, + endLine: lineCount, + lineCount, + text: '', + truncated: false, + }; + } + const safeEndLine = Math.min(endLine, lineCount); + const text = model.getText({ + startLineNumber: startLine, + startColumn: 1, + endLineNumber: safeEndLine, + endColumn: model.getMonacoModel().getLineMaxColumn(safeEndLine), + }); + return { + uri: uri.toString(), + path: uri.codeUri.fsPath, + startLine, + endLine: safeEndLine, + lineCount, + text: text.slice(0, maxChars), + truncated: text.length > maxChars, + }; + }); + if (!data) { + return serviceUnavailableResult('IEditorDocumentModelService'); + } + return successResult(data); + } catch (err) { + return errorResult(classifyError(err), err); + } + }, + }, + + // ----- editor_list_dirty_files ----- + { + name: 'editor_list_dirty_files', + description: 'List unsaved editor buffers.', + riskLevel: 'read', + inputSchema: { + type: 'object', + properties: {}, + }, + execute: async () => { + const documentModelService = tryGetService( + container, + IEditorDocumentModelService, + ); + if (!documentModelService) { + return serviceUnavailableResult('IEditorDocumentModelService'); + } + try { + const files = documentModelService + .getAllModels() + .filter((model) => model.dirty) + .map((model) => ({ + uri: model.uri.toString(), + path: model.uri.codeUri.fsPath, + languageId: model.languageId, + savable: model.savable, + readonly: model.readonly, + })); + return successResult({ files, total: files.length }); + } catch (err) { + return errorResult(classifyError(err), err); + } + }, + }, + + // ----- editor_get_dirty_diff ----- + { + name: 'editor_get_dirty_diff', + description: 'Return a compact diff between disk content and an unsaved editor buffer.', + riskLevel: 'read', + inputSchema: { + type: 'object', + properties: { + path: { + type: 'string', + description: 'Optional file path or file URI. Defaults to the active editor.', + }, + maxLines: { + type: 'number', + description: 'Maximum diff lines to return. Defaults to 200, capped at 1000.', + }, + }, + }, + execute: async (params: Record) => { + const editorService = tryGetService(container, WorkbenchEditorService); + if (!editorService) { + return serviceUnavailableResult('WorkbenchEditorService'); + } + const fileService = tryGetService(container, IFileServiceClient); + if (!fileService) { + return serviceUnavailableResult('IFileServiceClient'); + } + try { + const uri = + typeof params.path === 'string' && params.path + ? resolveEditorUri(container, params.path) + : editorService.currentEditor?.currentUri; + if (!uri) { + return errorResult('INVALID_INPUT', new Error('path is required when no active editor exists')); + } + const maxLines = toPositiveCappedNumber(params.maxLines, 200, 1000); + const fileStat = await fileService.getFileStat(uri.toString()); + const diskText = fileStat ? (await fileService.readFile(uri.toString())).content.toString() : ''; + const data = await withDocumentModel(container, uri, (model) => { + const bufferText = model.getText(); + const { diff, truncated } = createSimpleDiff(diskText, bufferText, maxLines); + return { + uri: uri.toString(), + path: uri.codeUri.fsPath, + dirty: model.dirty, + diff, + truncated, + }; + }); + if (!data) { + return serviceUnavailableResult('IEditorDocumentModelService'); + } + return successResult(data); + } catch (err) { + return errorResult(classifyError(err), err); + } + }, + }, + + // ----- editor_set_selection ----- + { + name: 'editor_set_selection', + 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.', + riskLevel: 'ui', + 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); + } + }, + }, + + // ----- editor_format ----- + { + name: 'editor_format', + description: 'Format the document at the given path using the editor format command.', + riskLevel: 'write', + profiles: ['full'], + 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 { + const resolved = await resolveWorkspaceEditorUri(container, filePath, 'write'); + if (!resolved.ok) { + return resolved.result; + } + // Open the file first to ensure it is the active editor. + const uri = resolved.uri; + await editorService.open(uri, { focus: true }); + await commandService.executeCommand('editor.action.formatDocument'); + return successResult({ path: resolved.absolutePath, formatted: true }); + } catch (err) { + return errorResult(classifyError(err), err); + } + }, + }, + + // ----- editor_fold ----- + { + name: 'editor_fold', + description: 'Fold code at the specified line in the editor. Opens the file first if needed.', + riskLevel: 'ui', + profiles: ['interactive', 'full'], + 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); + } + }, + }, + + // ----- editor_unfold ----- + { + name: 'editor_unfold', + description: 'Unfold code at the specified line in the editor. Opens the file first if needed.', + riskLevel: 'ui', + profiles: ['interactive', 'full'], + 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); + } + }, + }, + + // ----- editor_save ----- + { + name: 'editor_save', + description: 'Save the file at the given path.', + riskLevel: 'write', + profiles: ['full'], + 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 resolved = await resolveWorkspaceEditorUri(container, filePath, 'write'); + if (!resolved.ok) { + return resolved.result; + } + const isOpen = editorService.editorGroups.some((group) => + group.resources.some((resource) => resource.uri.isEqual(resolved.uri)), + ); + if (!isOpen) { + return errorResult('INVALID_INPUT', new Error(`Editor is not open for path: ${filePath}`)); + } + const savedUri = await editorService.save(resolved.uri); + if (!savedUri) { + return errorResult('FILE_NOT_FOUND', new Error(`Editor not found for path: ${filePath}`)); + } + return successResult({ path: resolved.absolutePath, saved: true }); + } catch (err) { + return errorResult(classifyError(err), err); + } + }, + }, + ], + }; +} diff --git a/packages/ai-native/src/browser/acp/webmcp-groups/file-workspace-path.ts b/packages/ai-native/src/browser/acp/webmcp-groups/file-workspace-path.ts new file mode 100644 index 0000000000..fe5f720580 --- /dev/null +++ b/packages/ai-native/src/browser/acp/webmcp-groups/file-workspace-path.ts @@ -0,0 +1,184 @@ +import { URI, path } from '@opensumi/ide-core-common'; +import { IFileServiceClient } from '@opensumi/ide-file-service'; + +import type { FileStat } from '@opensumi/ide-core-common'; + +type PathModule = typeof path.posix; + +export interface WorkspacePathResolution { + absolutePath: string; + uri: string; + pathModule: PathModule; +} + +export type WorkspacePathResult = + | { + ok: true; + value: WorkspacePathResolution; + } + | { + ok: false; + message: string; + }; + +const URI_SCHEME_PATTERN = /^[a-zA-Z][a-zA-Z0-9+.-]*:/; +const WINDOWS_DRIVE_ABSOLUTE_PATTERN = /^[a-zA-Z]:[\\/]/; +const WINDOWS_DRIVE_RELATIVE_PATTERN = /^[a-zA-Z]:(?![\\/])/; +const WINDOWS_UNC_PATTERN = /^(?:\\\\|\/\/)[^\\/]+[\\/][^\\/]+/; + +function isWindowsPath(value: string): boolean { + return ( + WINDOWS_DRIVE_ABSOLUTE_PATTERN.test(value) || WINDOWS_DRIVE_RELATIVE_PATTERN.test(value) || value.includes('\\') + ); +} + +function selectPathModule(workspaceDir: string, inputPath: string): PathModule { + return isWindowsPath(workspaceDir) || isWindowsPath(inputPath) ? path.win32 : path.posix; +} + +function isUriString(value: string): boolean { + return ( + URI_SCHEME_PATTERN.test(value) && + !WINDOWS_DRIVE_ABSOLUTE_PATTERN.test(value) && + !WINDOWS_DRIVE_RELATIVE_PATTERN.test(value) + ); +} + +function isPathInsideWorkspace(pathModule: PathModule, workspaceRoot: string, targetPath: string): boolean { + const relative = pathModule.relative(workspaceRoot, targetPath); + return relative === '' || (!relative.startsWith('..') && !pathModule.isAbsolute(relative)); +} + +export function resolveWorkspaceFilePath(workspaceDir: string, inputPath: unknown): WorkspacePathResult { + if (typeof inputPath !== 'string' || !inputPath.trim()) { + return { ok: false, message: 'path is required' }; + } + if (!workspaceDir) { + return { ok: false, message: 'workspaceDir is required' }; + } + + const rawPath = inputPath.trim(); + if (isUriString(rawPath)) { + return { + ok: false, + message: 'URI paths are not supported; pass a workspace-relative path or workspace-local absolute path', + }; + } + if (WINDOWS_DRIVE_RELATIVE_PATTERN.test(rawPath)) { + return { ok: false, message: 'Windows drive-relative paths are not supported' }; + } + + const pathModule = selectPathModule(workspaceDir, rawPath); + const workspaceRoot = pathModule.resolve(workspaceDir); + const isAbsolute = pathModule.isAbsolute(rawPath) || WINDOWS_UNC_PATTERN.test(rawPath); + const absolutePath = isAbsolute ? pathModule.resolve(rawPath) : pathModule.resolve(workspaceRoot, rawPath); + + if (!isPathInsideWorkspace(pathModule, workspaceRoot, absolutePath)) { + return { ok: false, message: 'Path is outside of the workspace' }; + } + + return { + ok: true, + value: { + absolutePath, + uri: URI.file(absolutePath).toString(), + pathModule, + }, + }; +} + +export function validateWorkspaceFileStat( + workspaceDir: string, + stat: FileStat | undefined, + pathModule: PathModule, +): WorkspacePathResult { + if (!stat) { + return { ok: false, message: 'File stat is required' }; + } + if (!stat.isSymbolicLink) { + return { + ok: true, + value: { + absolutePath: URI.parse(stat.uri).codeUri.fsPath, + uri: stat.uri, + pathModule, + }, + }; + } + if (!stat.realUri) { + return { ok: false, message: 'Cannot verify symbolic link target' }; + } + + const realPath = URI.parse(stat.realUri).codeUri.fsPath; + const workspaceRoot = pathModule.resolve(workspaceDir); + const realAbsolutePath = pathModule.resolve(realPath); + if (!isPathInsideWorkspace(pathModule, workspaceRoot, realAbsolutePath)) { + return { ok: false, message: 'Symbolic link target is outside of the workspace' }; + } + + return { + ok: true, + value: { + absolutePath: realAbsolutePath, + uri: stat.realUri, + pathModule, + }, + }; +} + +export async function validateWorkspacePathAccess( + fileService: IFileServiceClient, + workspaceDir: string, + resolution: WorkspacePathResolution, +): Promise { + const workspaceRoot = resolution.pathModule.resolve(workspaceDir); + let currentPath = resolution.absolutePath; + + while (isPathInsideWorkspace(resolution.pathModule, workspaceRoot, currentPath)) { + const currentStat = await fileService.getFileStat(URI.file(currentPath).toString()); + if (currentStat?.isSymbolicLink) { + const statValidation = validateWorkspaceFileStat(workspaceDir, currentStat, resolution.pathModule); + if (!statValidation.ok) { + return statValidation; + } + } + + if (currentPath === workspaceRoot) { + break; + } + const parentPath = resolution.pathModule.dirname(currentPath); + if (parentPath === currentPath) { + break; + } + currentPath = parentPath; + } + + return { ok: true, value: resolution }; +} + +export async function validateWritableWorkspaceTarget( + fileService: IFileServiceClient, + workspaceDir: string, + resolution: WorkspacePathResolution, +): Promise { + const existingStat = await fileService.getFileStat(resolution.uri); + if (existingStat) { + return validateWorkspaceFileStat(workspaceDir, existingStat, resolution.pathModule); + } + + const workspaceRoot = resolution.pathModule.resolve(workspaceDir); + let currentPath = resolution.pathModule.dirname(resolution.absolutePath); + while (isPathInsideWorkspace(resolution.pathModule, workspaceRoot, currentPath)) { + const currentStat = await fileService.getFileStat(URI.file(currentPath).toString()); + if (currentStat) { + return validateWorkspaceFileStat(workspaceDir, currentStat, resolution.pathModule); + } + const parentPath = resolution.pathModule.dirname(currentPath); + if (parentPath === currentPath) { + break; + } + currentPath = parentPath; + } + + return { ok: false, message: 'Cannot verify writable target parent inside workspace' }; +} 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..b3920d81c7 --- /dev/null +++ b/packages/ai-native/src/browser/acp/webmcp-groups/file.webmcp-group.ts @@ -0,0 +1,709 @@ +/** + * WebMCP group definition for file management. + * + * Defines file_* capabilities once for both navigator.modelContext and + * the Node-side MCP server. + * + * Tools follow the naming convention: file_{action} + */ +import { Injector } from '@opensumi/di'; +import { AppConfig } from '@opensumi/ide-core-browser'; +import { IFileServiceClient } from '@opensumi/ide-file-service'; + +import { WebMcpGroupRegistration } from '../webmcp-group-registry'; +import { classifyError, errorResult, serviceUnavailableResult, successResult, tryGetService } from '../webmcp-utils'; + +import { + resolveWorkspaceFilePath, + validateWorkspaceFileStat, + validateWorkspacePathAccess, + validateWritableWorkspaceTarget, +} from './file-workspace-path'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function invalidPathResult(message: string) { + return errorResult('INVALID_INPUT', new Error(message)); +} + +function getStringParam(params: Record, primaryKey: string, fallbackKey?: string): string { + const value = params[primaryKey] ?? (fallbackKey ? params[fallbackKey] : undefined); + return typeof value === 'string' ? value : ''; +} + +// --------------------------------------------------------------------------- +// Group definition +// --------------------------------------------------------------------------- + +export function createFileGroup(container: Injector): WebMcpGroupRegistration { + return { + name: 'file', + description: '文件读写和管理操作', + defaultLoaded: true, + tools: [ + // ----- file_get_workspace_root ----- + { + name: 'file_get_workspace_root', + description: + 'Get the absolute path of the current workspace root directory. Use this to understand the base path for relative file operations.', + riskLevel: 'read', + profiles: ['interactive', 'full'], + 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); + } + }, + }, + + // ----- file_read ----- + { + name: 'file_read', + description: + 'Read the contents of a file. Returns the file content as text. Use relative paths from the workspace root.', + riskLevel: 'read', + profiles: ['interactive', 'full'], + 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 resolved = resolveWorkspaceFilePath(appConfig.workspaceDir, filePath); + if (!resolved.ok) { + return invalidPathResult(resolved.message); + } + const accessValidation = await validateWorkspacePathAccess( + fileService, + appConfig.workspaceDir, + resolved.value, + ); + if (!accessValidation.ok) { + return invalidPathResult(accessValidation.message); + } + const uri = resolved.value.uri; + const fileStat = await fileService.getFileStat(uri); + if (!fileStat) { + return errorResult('FILE_NOT_FOUND', new Error(`File not found: ${filePath}`)); + } + const statValidation = validateWorkspaceFileStat( + appConfig.workspaceDir, + fileStat, + resolved.value.pathModule, + ); + if (!statValidation.ok) { + return invalidPathResult(statValidation.message); + } + 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); + } + }, + }, + + // ----- file_write ----- + { + 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.', + riskLevel: 'write', + profiles: ['full'], + 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 resolved = resolveWorkspaceFilePath(appConfig.workspaceDir, filePath); + if (!resolved.ok) { + return invalidPathResult(resolved.message); + } + const targetValidation = await validateWritableWorkspaceTarget( + fileService, + appConfig.workspaceDir, + resolved.value, + ); + if (!targetValidation.ok) { + return invalidPathResult(targetValidation.message); + } + const uri = resolved.value.uri; + 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); + } + }, + }, + + // ----- file_list ----- + { + name: 'file_list', + description: + 'List the contents of a directory. Returns an array of file/directory entries with metadata. Use "." for the workspace root.', + riskLevel: 'read', + profiles: ['interactive', 'full'], + 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 resolved = resolveWorkspaceFilePath(appConfig.workspaceDir, dirPath); + if (!resolved.ok) { + return invalidPathResult(resolved.message); + } + const accessValidation = await validateWorkspacePathAccess( + fileService, + appConfig.workspaceDir, + resolved.value, + ); + if (!accessValidation.ok) { + return invalidPathResult(accessValidation.message); + } + const uri = resolved.value.uri; + const fileStat = await fileService.getFileStat(uri, true); + if (!fileStat) { + return errorResult('FILE_NOT_FOUND', new Error(`Directory not found: ${dirPath}`)); + } + const statValidation = validateWorkspaceFileStat( + appConfig.workspaceDir, + fileStat, + resolved.value.pathModule, + ); + if (!statValidation.ok) { + return invalidPathResult(statValidation.message); + } + 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); + } + }, + }, + + // ----- file_stat ----- + { + name: 'file_stat', + description: + 'Get metadata about a file or directory. Returns size, isDirectory, lastModified, and other stat info.', + riskLevel: 'read', + profiles: ['interactive', 'full'], + 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 resolved = resolveWorkspaceFilePath(appConfig.workspaceDir, filePath); + if (!resolved.ok) { + return invalidPathResult(resolved.message); + } + const accessValidation = await validateWorkspacePathAccess( + fileService, + appConfig.workspaceDir, + resolved.value, + ); + if (!accessValidation.ok) { + return invalidPathResult(accessValidation.message); + } + const uri = resolved.value.uri; + const fileStat = await fileService.getFileStat(uri); + if (!fileStat) { + return errorResult('FILE_NOT_FOUND', new Error(`Path not found: ${filePath}`)); + } + const statValidation = validateWorkspaceFileStat( + appConfig.workspaceDir, + fileStat, + resolved.value.pathModule, + ); + if (!statValidation.ok) { + return invalidPathResult(statValidation.message); + } + return successResult({ + path: filePath, + isDirectory: fileStat.isDirectory, + size: fileStat.size, + lastModified: fileStat.lastModification, + isReadonly: fileStat.readonly, + }); + } catch (err) { + return errorResult(classifyError(err), err); + } + }, + }, + + // ----- file_exists ----- + { + name: 'file_exists', + description: 'Check whether a file or directory exists at the given path. Returns true or false.', + riskLevel: 'read', + profiles: ['interactive', 'full'], + 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 resolved = resolveWorkspaceFilePath(appConfig.workspaceDir, filePath); + if (!resolved.ok) { + return invalidPathResult(resolved.message); + } + const accessValidation = await validateWorkspacePathAccess( + fileService, + appConfig.workspaceDir, + resolved.value, + ); + if (!accessValidation.ok) { + return invalidPathResult(accessValidation.message); + } + const fileStat = await fileService.getFileStat(resolved.value.uri); + if (fileStat) { + const statValidation = validateWorkspaceFileStat( + appConfig.workspaceDir, + fileStat, + resolved.value.pathModule, + ); + if (!statValidation.ok) { + return invalidPathResult(statValidation.message); + } + } + const exists = !!fileStat; + return successResult({ path: filePath, exists }); + } catch (err) { + return errorResult(classifyError(err), err); + } + }, + }, + + // ----- file_create ----- + { + name: 'file_create', + description: 'Create a new file with optional content. Use "type: directory" to create a folder instead.', + riskLevel: 'write', + profiles: ['full'], + 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".', + }, + content: { + type: 'string', + description: 'Initial file content. Ignored when type is "directory".', + }, + }, + required: ['path'], + }, + execute: async (params: Record) => { + const filePath = params.path as string; + const createType = (params.type as 'file' | 'directory') || 'file'; + const content = typeof params.content === 'string' ? params.content : undefined; + 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 resolved = resolveWorkspaceFilePath(appConfig.workspaceDir, filePath); + if (!resolved.ok) { + return invalidPathResult(resolved.message); + } + const targetValidation = await validateWritableWorkspaceTarget( + fileService, + appConfig.workspaceDir, + resolved.value, + ); + if (!targetValidation.ok) { + return invalidPathResult(targetValidation.message); + } + const uri = resolved.value.uri; + 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, { content }); + } + return successResult({ path: filePath, type: createType, created: true }); + } catch (err) { + return errorResult(classifyError(err), err); + } + }, + }, + + // ----- file_delete ----- + { + name: 'file_delete', + description: 'Delete a file or directory. Use recursive: true to delete a directory and its contents.', + riskLevel: 'destructive', + profiles: ['full'], + 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 resolved = resolveWorkspaceFilePath(appConfig.workspaceDir, filePath); + if (!resolved.ok) { + return invalidPathResult(resolved.message); + } + const accessValidation = await validateWorkspacePathAccess( + fileService, + appConfig.workspaceDir, + resolved.value, + ); + if (!accessValidation.ok) { + return invalidPathResult(accessValidation.message); + } + const uri = resolved.value.uri; + const existingStat = await fileService.getFileStat(uri); + if (!existingStat) { + return errorResult('FILE_NOT_FOUND', new Error(`Path not found: ${filePath}`)); + } + const statValidation = validateWorkspaceFileStat( + appConfig.workspaceDir, + existingStat, + resolved.value.pathModule, + ); + if (!statValidation.ok) { + return invalidPathResult(statValidation.message); + } + 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); + } + }, + }, + + // ----- file_move ----- + { + name: 'file_move', + description: 'Move or rename a file or directory from source to destination.', + riskLevel: 'write', + profiles: ['full'], + inputSchema: { + type: 'object', + properties: { + sourcePath: { + type: 'string', + description: 'The relative source path to move, from the workspace root.', + }, + targetPath: { + type: 'string', + description: 'The relative destination path to move to, from the workspace root.', + }, + }, + required: ['sourcePath', 'targetPath'], + }, + execute: async (params: Record) => { + const source = getStringParam(params, 'sourcePath', 'source'); + const destination = getStringParam(params, 'targetPath', 'destination'); + if (!source || !destination) { + return errorResult('INVALID_INPUT', new Error('sourcePath and targetPath 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 sourceResolved = resolveWorkspaceFilePath(appConfig.workspaceDir, source); + if (!sourceResolved.ok) { + return invalidPathResult(sourceResolved.message); + } + const sourceAccessValidation = await validateWorkspacePathAccess( + fileService, + appConfig.workspaceDir, + sourceResolved.value, + ); + if (!sourceAccessValidation.ok) { + return invalidPathResult(sourceAccessValidation.message); + } + const destinationResolved = resolveWorkspaceFilePath(appConfig.workspaceDir, destination); + if (!destinationResolved.ok) { + return invalidPathResult(destinationResolved.message); + } + const sourceUri = sourceResolved.value.uri; + const destinationUri = destinationResolved.value.uri; + const sourceStat = await fileService.getFileStat(sourceUri); + if (!sourceStat) { + return errorResult('FILE_NOT_FOUND', new Error(`Source not found: ${source}`)); + } + const sourceValidation = validateWorkspaceFileStat( + appConfig.workspaceDir, + sourceStat, + sourceResolved.value.pathModule, + ); + if (!sourceValidation.ok) { + return invalidPathResult(sourceValidation.message); + } + const destinationValidation = await validateWritableWorkspaceTarget( + fileService, + appConfig.workspaceDir, + destinationResolved.value, + ); + if (!destinationValidation.ok) { + return invalidPathResult(destinationValidation.message); + } + await fileService.move(sourceUri, destinationUri); + return successResult({ source, destination, moved: true }); + } catch (err) { + return errorResult(classifyError(err), err); + } + }, + }, + + // ----- file_copy ----- + { + name: 'file_copy', + description: 'Copy a file or directory from source to destination.', + riskLevel: 'write', + profiles: ['full'], + inputSchema: { + type: 'object', + properties: { + sourcePath: { + type: 'string', + description: 'The relative source path to copy, from the workspace root.', + }, + targetPath: { + type: 'string', + description: 'The relative destination path to copy to, from the workspace root.', + }, + }, + required: ['sourcePath', 'targetPath'], + }, + execute: async (params: Record) => { + const source = getStringParam(params, 'sourcePath', 'source'); + const destination = getStringParam(params, 'targetPath', 'destination'); + if (!source || !destination) { + return errorResult('INVALID_INPUT', new Error('sourcePath and targetPath 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 sourceResolved = resolveWorkspaceFilePath(appConfig.workspaceDir, source); + if (!sourceResolved.ok) { + return invalidPathResult(sourceResolved.message); + } + const sourceAccessValidation = await validateWorkspacePathAccess( + fileService, + appConfig.workspaceDir, + sourceResolved.value, + ); + if (!sourceAccessValidation.ok) { + return invalidPathResult(sourceAccessValidation.message); + } + const destinationResolved = resolveWorkspaceFilePath(appConfig.workspaceDir, destination); + if (!destinationResolved.ok) { + return invalidPathResult(destinationResolved.message); + } + const sourceUri = sourceResolved.value.uri; + const destinationUri = destinationResolved.value.uri; + const sourceStat = await fileService.getFileStat(sourceUri); + if (!sourceStat) { + return errorResult('FILE_NOT_FOUND', new Error(`Source not found: ${source}`)); + } + const sourceValidation = validateWorkspaceFileStat( + appConfig.workspaceDir, + sourceStat, + sourceResolved.value.pathModule, + ); + if (!sourceValidation.ok) { + return invalidPathResult(sourceValidation.message); + } + const destinationValidation = await validateWritableWorkspaceTarget( + fileService, + appConfig.workspaceDir, + destinationResolved.value, + ); + if (!destinationValidation.ok) { + return invalidPathResult(destinationValidation.message); + } + 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/opensumi-mcp.webmcp-group.ts b/packages/ai-native/src/browser/acp/webmcp-groups/opensumi-mcp.webmcp-group.ts new file mode 100644 index 0000000000..51501748b5 --- /dev/null +++ b/packages/ai-native/src/browser/acp/webmcp-groups/opensumi-mcp.webmcp-group.ts @@ -0,0 +1,40 @@ +/** + * WebMCP group definition for discovering the built-in OpenSumi MCP transport. + */ +import { Injector } from '@opensumi/di'; +import { AIBackSerivcePath, IAIBackService } from '@opensumi/ide-core-common'; + +import { WebMcpGroupRegistration } from '../webmcp-group-registry'; +import { classifyError, errorResult, serviceUnavailableResult, successResult, tryGetService } from '../webmcp-utils'; + +export function createOpenSumiMcpGroup(container: Injector): WebMcpGroupRegistration { + return { + name: 'opensumi_mcp', + description: 'OpenSumi built-in MCP transport discovery', + defaultLoaded: true, + tools: [ + { + name: 'opensumi_get_mcp_server_connection', + description: + 'Start the built-in opensumi-ide MCP server and return a local Streamable HTTP connection descriptor. Use redactedUrl for logs.', + riskLevel: 'read', + inputSchema: { + type: 'object', + properties: {}, + additionalProperties: false, + }, + execute: async () => { + const aiBackService = tryGetService(container, AIBackSerivcePath); + if (!aiBackService?.getOpenSumiMcpServerConnection) { + return serviceUnavailableResult('AIBackService.getOpenSumiMcpServerConnection'); + } + try { + return successResult(await aiBackService.getOpenSumiMcpServerConnection()); + } catch (err) { + return errorResult(classifyError(err), err); + } + }, + }, + ], + }; +} diff --git a/packages/ai-native/src/browser/acp/webmcp-groups/search.webmcp-group.ts b/packages/ai-native/src/browser/acp/webmcp-groups/search.webmcp-group.ts new file mode 100644 index 0000000000..683becd9b4 --- /dev/null +++ b/packages/ai-native/src/browser/acp/webmcp-groups/search.webmcp-group.ts @@ -0,0 +1,312 @@ +/** + * WebMCP group definition for IDE-backed search operations. + */ +import { Injector } from '@opensumi/di'; +import { getValidateInput } from '@opensumi/ide-addons/lib/browser/file-search.contribution'; +import { CancellationToken, CancellationTokenSource, URI } from '@opensumi/ide-core-common'; +import { defaultFilesWatcherExcludes } from '@opensumi/ide-core-common/lib/preferences/file-watch'; +import { ILanguageService } from '@opensumi/ide-editor/lib/common/language'; +import { FileSearchServicePath, IFileSearchService } from '@opensumi/ide-file-search/lib/common'; +import { ContentSearchClientService } from '@opensumi/ide-search/lib/browser/search.service'; +import { ContentSearchResult, IContentSearchClientService, SEARCH_STATE } from '@opensumi/ide-search/lib/common'; +import { IWorkspaceService } from '@opensumi/ide-workspace'; + +import { WebMcpGroupRegistration } from '../webmcp-group-registry'; +import { classifyError, errorResult, serviceUnavailableResult, successResult, tryGetService } from '../webmcp-utils'; + +const DEFAULT_FILE_RESULTS = 20; +const MAX_FILE_RESULTS = 100; +const DEFAULT_TEXT_RESULTS = 50; +const MAX_TEXT_RESULTS = 200; +const SEARCH_TIMEOUT_MS = 10_000; + +function getWorkspaceRootPaths(workspaceService: IWorkspaceService): string[] { + return workspaceService.tryGetRoots().map((root) => URI.parse(root.uri).codeUri.fsPath); +} + +function asStringArray(value: unknown): string[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + return value.filter((item): item is string => typeof item === 'string' && item.length > 0); +} + +function toPositiveCappedNumber(value: unknown, fallback: number, cap: number): number { + return Math.min(Math.max(Number(value) || fallback, 1), cap); +} + +function waitForSearchDone( + searchService: ContentSearchClientService, + timeoutMs: number, +): Promise<{ timedOut: boolean }> { + return new Promise((resolve) => { + let settled = false; + let disposable: { dispose(): void } | undefined; + + const finish = (timedOut: boolean) => { + if (settled) { + return; + } + settled = true; + disposable?.dispose(); + clearTimeout(timer); + resolve({ timedOut }); + }; + + const timer = setTimeout(() => finish(true), timeoutMs); + disposable = searchService.onDidChange(() => { + if (!searchService.isSearching) { + finish(false); + } + }); + + if (!searchService.isSearching) { + finish(false); + } + }); +} + +function flattenSearchResults(searchResults: Map, maxResults: number) { + const results = Array.from(searchResults.entries()).flatMap(([fileUri, matches]) => { + const path = URI.parse(fileUri).codeUri.fsPath; + return matches.map((match) => ({ + uri: fileUri, + path, + line: match.line, + matchStart: match.matchStart, + matchLength: match.matchLength, + lineText: match.lineText ?? match.renderLineText ?? '', + })); + }); + return results.slice(0, maxResults); +} + +export function createSearchGroup(container: Injector): WebMcpGroupRegistration { + return { + name: 'search', + description: 'Workspace file, text, and symbol search', + defaultLoaded: true, + tools: [ + { + name: 'search_files', + description: + 'Search workspace files by fuzzy filename or path. Prefer this before reading files when the exact path is unknown.', + riskLevel: 'read', + profiles: ['interactive', 'full'], + inputSchema: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'Filename or path fragment to search for.', + }, + maxResults: { + type: 'number', + description: 'Maximum number of files to return. Defaults to 20, capped at 100.', + }, + includePatterns: { + type: 'array', + items: { type: 'string' }, + description: 'Optional glob patterns to include.', + }, + excludePatterns: { + type: 'array', + items: { type: 'string' }, + description: 'Optional glob patterns to exclude.', + }, + }, + required: ['query'], + }, + execute: async (params: Record) => { + const query = typeof params.query === 'string' ? params.query.trim() : ''; + if (!query) { + return errorResult('INVALID_INPUT', new Error('query is required')); + } + const workspaceService = tryGetService(container, IWorkspaceService); + if (!workspaceService) { + return serviceUnavailableResult('IWorkspaceService'); + } + const fileSearchService = tryGetService(container, FileSearchServicePath); + if (!fileSearchService) { + return serviceUnavailableResult('FileSearchServicePath'); + } + try { + const rootUris = getWorkspaceRootPaths(workspaceService); + const maxResults = toPositiveCappedNumber(params.maxResults, DEFAULT_FILE_RESULTS, MAX_FILE_RESULTS); + const searchPattern = getValidateInput(query.replace(/\s/g, '')); + const results = await fileSearchService.find(searchPattern, { + rootUris, + excludePatterns: [ + ...Object.keys(defaultFilesWatcherExcludes), + ...(asStringArray(params.excludePatterns) ?? []), + ], + includePatterns: asStringArray(params.includePatterns), + limit: maxResults, + useGitIgnore: true, + noIgnoreParent: true, + fuzzyMatch: true, + }); + return successResult({ + query, + files: results.slice(0, maxResults).map((path) => ({ path })), + total: results.length, + truncated: results.length > maxResults, + }); + } catch (err) { + return errorResult(classifyError(err), err); + } + }, + }, + { + name: 'search_text', + description: + 'Search text across workspace files. Returns matching file path, line, column, and a shortened line preview.', + riskLevel: 'read', + profiles: ['interactive', 'full'], + inputSchema: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'Text or regular expression to search for.', + }, + maxResults: { + type: 'number', + description: 'Maximum number of matches to return. Defaults to 50, capped at 200.', + }, + include: { + type: 'array', + items: { type: 'string' }, + description: 'Optional glob patterns to include.', + }, + exclude: { + type: 'array', + items: { type: 'string' }, + description: 'Optional glob patterns to exclude.', + }, + matchCase: { + type: 'boolean', + description: 'Whether matching is case-sensitive.', + }, + matchWholeWord: { + type: 'boolean', + description: 'Whether to match whole words only.', + }, + useRegExp: { + type: 'boolean', + description: 'Whether query is a regular expression.', + }, + includeIgnored: { + type: 'boolean', + description: 'Whether to include gitignored and hidden files.', + }, + }, + required: ['query'], + }, + execute: async (params: Record) => { + const query = typeof params.query === 'string' ? params.query : ''; + if (!query) { + return errorResult('INVALID_INPUT', new Error('query is required')); + } + const searchService = tryGetService(container, IContentSearchClientService); + if (!searchService) { + return serviceUnavailableResult('IContentSearchClientService'); + } + try { + const maxResults = toPositiveCappedNumber(params.maxResults, DEFAULT_TEXT_RESULTS, MAX_TEXT_RESULTS); + const cancellation = new CancellationTokenSource(); + searchService.cleanSearchResults(); + await searchService.doSearch( + query, + { + ...searchService.UIState, + isMatchCase: Boolean(params.matchCase), + isWholeWord: Boolean(params.matchWholeWord), + isUseRegexp: Boolean(params.useRegExp), + isIncludeIgnored: Boolean(params.includeIgnored), + include: asStringArray(params.include), + exclude: asStringArray(params.exclude), + maxResults, + }, + cancellation.token, + ); + const { timedOut } = await waitForSearchDone(searchService, SEARCH_TIMEOUT_MS); + if (timedOut) { + cancellation.cancel(); + } + const matches = flattenSearchResults(searchService.searchResults, maxResults); + return successResult({ + query, + matches, + total: searchService.resultTotal.resultNum, + fileTotal: searchService.resultTotal.fileNum, + truncated: searchService.resultTotal.resultNum > matches.length, + timedOut, + searchState: SEARCH_STATE[searchService.searchState], + error: searchService.searchError || undefined, + }); + } catch (err) { + return errorResult(classifyError(err), err); + } + }, + }, + { + name: 'search_symbols', + description: 'Search workspace symbols through registered language providers.', + riskLevel: 'read', + profiles: ['interactive', 'full'], + inputSchema: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'Symbol name or partial name to search for.', + }, + maxResults: { + type: 'number', + description: 'Maximum number of symbols to return. Defaults to 50, capped at 200.', + }, + }, + required: ['query'], + }, + execute: async (params: Record) => { + const query = typeof params.query === 'string' ? params.query.trim() : ''; + if (!query) { + return errorResult('INVALID_INPUT', new Error('query is required')); + } + const languageService = tryGetService(container, ILanguageService); + if (!languageService) { + return serviceUnavailableResult('ILanguageService'); + } + try { + const maxResults = toPositiveCappedNumber(params.maxResults, DEFAULT_TEXT_RESULTS, MAX_TEXT_RESULTS); + const providerResults = await Promise.all( + languageService.workspaceSymbolProviders.map((provider) => + Promise.resolve(provider.provideWorkspaceSymbols({ query }, CancellationToken.None)).catch(() => []), + ), + ); + const symbols = providerResults + .flat() + .slice(0, maxResults) + .map((symbol) => ({ + name: symbol.name, + kind: symbol.kind, + containerName: symbol.containerName, + uri: symbol.location.uri, + path: URI.parse(symbol.location.uri).codeUri.fsPath, + range: symbol.location.range, + })); + return successResult({ + query, + symbols, + total: providerResults.reduce((count, result) => count + result.length, 0), + truncated: providerResults.reduce((count, result) => count + result.length, 0) > symbols.length, + }); + } 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..ec41d7e628 --- /dev/null +++ b/packages/ai-native/src/browser/acp/webmcp-groups/terminal.webmcp-group.ts @@ -0,0 +1,804 @@ +/** + * Terminal WebMCP group definition for the ACP channel. + * + * Defines terminal_* capabilities once for both navigator.modelContext and + * the Node-side MCP server. + * + * Tools follow the naming convention: terminal_{action} + */ +import { Injector } from '@opensumi/di'; +import { ITerminalClient, 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'; + +const DEFAULT_TERMINAL_LINES = 120; +const MAX_TERMINAL_LINES = 1000; +const MAX_WAIT_TIMEOUT_MS = 60_000; + +function toPositiveCappedNumber(value: unknown, fallback: number, cap: number): number { + return Math.min(Math.max(Number(value) || fallback, 1), cap); +} + +function stripAnsi(value: string): string { + return value.replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, ''); +} + +function getTerminalClient(controller: ITerminalController, id?: string): ITerminalClient | undefined { + if (id) { + return controller.clients.get(id); + } + return controller.activeClient ?? Array.from(controller.clients.values()).find((client) => client.id); +} + +function readTerminalBuffer( + client: ITerminalClient, + options: { cursor?: unknown; maxLines?: unknown; stripAnsi?: unknown }, +): { terminalId: string; cursor: string; lines: string[]; totalBufferLines: number; truncated: boolean } { + const maxLines = toPositiveCappedNumber(options.maxLines, DEFAULT_TERMINAL_LINES, MAX_TERMINAL_LINES); + const buffer = client.term.buffer.active; + const totalBufferLines = buffer.length; + const parsedCursor = typeof options.cursor === 'string' ? Number(options.cursor) : Number(options.cursor); + const start = Number.isFinite(parsedCursor) + ? Math.min(Math.max(parsedCursor, 0), totalBufferLines) + : Math.max(totalBufferLines - maxLines, 0); + const end = Math.min(start + maxLines, totalBufferLines); + const shouldStripAnsi = options.stripAnsi !== false; + const lines: string[] = []; + for (let index = start; index < end; index++) { + const text = buffer.getLine(index)?.translateToString(true) ?? ''; + lines.push(shouldStripAnsi ? stripAnsi(text) : text); + } + return { + terminalId: client.id, + cursor: String(end), + lines, + totalBufferLines, + truncated: end < totalBufferLines, + }; +} + +function controlSequence(key: string): string | undefined { + const normalized = key.toLowerCase(); + const sequences: Record = { + enter: '\r', + 'ctrl-c': '\x03', + 'ctrl-d': '\x04', + escape: '\x1b', + tab: '\t', + up: '\x1b[A', + down: '\x1b[B', + right: '\x1b[C', + left: '\x1b[D', + }; + return sequences[normalized]; +} + +function wait(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export function createTerminalGroup(container: Injector): WebMcpGroupRegistration { + return { + name: 'terminal', + description: '终端操作', + defaultLoaded: true, + tools: [ + // ----- terminal_list ----- + { + name: '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.', + riskLevel: 'read', + 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); + } + }, + }, + + // ----- terminal_get_active ----- + { + name: 'terminal_get_active', + description: 'Get the active IDE terminal session.', + riskLevel: 'read', + inputSchema: { + type: 'object', + properties: {}, + }, + execute: async () => { + const terminalController = tryGetService(container, ITerminalController); + if (!terminalController) { + return serviceUnavailableResult('ITerminalController'); + } + try { + const client = getTerminalClient(terminalController); + if (!client) { + return successResult({ active: false, terminal: null }); + } + return successResult({ + active: true, + terminal: { + id: client.id, + name: client.name, + ready: client.ready, + cwd: client.launchConfig.cwd, + }, + }); + } catch (err) { + return errorResult('EXECUTION_ERROR', err); + } + }, + }, + + // ----- terminal_read_output ----- + { + name: 'terminal_read_output', + description: 'Read recent output lines from an IDE terminal.', + riskLevel: 'read', + inputSchema: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'Optional terminal session ID. Defaults to the active terminal.', + }, + maxLines: { + type: 'number', + description: 'Maximum lines to return. Defaults to 120, capped at 1000.', + }, + stripAnsi: { + type: 'boolean', + description: 'Whether to strip ANSI escape sequences. Defaults to true.', + }, + }, + }, + execute: async (params: Record) => { + const terminalController = tryGetService(container, ITerminalController); + if (!terminalController) { + return serviceUnavailableResult('ITerminalController'); + } + try { + const client = getTerminalClient(terminalController, params.id as string | undefined); + if (!client) { + return errorResult('INVALID_INPUT', new Error('terminal not found')); + } + return successResult( + readTerminalBuffer(client, { maxLines: params.maxLines, stripAnsi: params.stripAnsi }), + ); + } catch (err) { + return errorResult('EXECUTION_ERROR', err); + } + }, + }, + + // ----- terminal_tail ----- + { + name: 'terminal_tail', + description: 'Read output lines after a cursor from an IDE terminal.', + riskLevel: 'read', + inputSchema: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'Terminal session ID.', + }, + cursor: { + type: 'string', + description: 'Cursor returned by readOutput or tail.', + }, + maxLines: { + type: 'number', + description: 'Maximum lines to return. Defaults to 120, capped at 1000.', + }, + stripAnsi: { + type: 'boolean', + description: 'Whether to strip ANSI escape sequences. Defaults to true.', + }, + }, + required: ['id'], + }, + execute: async (params: Record) => { + const terminalController = tryGetService(container, ITerminalController); + if (!terminalController) { + return serviceUnavailableResult('ITerminalController'); + } + try { + const client = getTerminalClient(terminalController, params.id as string); + if (!client) { + return errorResult('INVALID_INPUT', new Error('terminal not found')); + } + return successResult( + readTerminalBuffer(client, { + cursor: params.cursor, + maxLines: params.maxLines, + stripAnsi: params.stripAnsi, + }), + ); + } catch (err) { + return errorResult('EXECUTION_ERROR', err); + } + }, + }, + + // ----- terminal_get_process_info ----- + { + name: 'terminal_get_process_info', + description: 'Get process metadata for an IDE terminal.', + riskLevel: 'read', + inputSchema: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'Optional terminal session ID. Defaults to the active terminal.', + }, + }, + }, + execute: async (params: Record) => { + const terminalController = tryGetService(container, ITerminalController); + if (!terminalController) { + return serviceUnavailableResult('ITerminalController'); + } + const terminalApi = tryGetService(container, ITerminalApiService); + if (!terminalApi) { + return serviceUnavailableResult('ITerminalApiService'); + } + try { + const client = getTerminalClient(terminalController, params.id as string | undefined); + if (!client) { + return errorResult('INVALID_INPUT', new Error('terminal not found')); + } + const pid = await terminalApi.getProcessId(client.id); + return successResult({ + terminalId: client.id, + name: client.name, + pid: pid ?? null, + cwd: client.launchConfig.cwd, + executable: client.launchConfig.executable, + ready: client.ready, + }); + } catch (err) { + return errorResult('EXECUTION_ERROR', err); + } + }, + }, + + // ----- terminal_create ----- + { + 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.', + riskLevel: 'shell', + profiles: ['interactive', 'full'], + 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); + } + }, + }, + + // ----- terminal_execute_command ----- + { + name: 'terminal_execute_command', + 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 terminal_list.', + riskLevel: 'shell', + profiles: ['interactive', 'full'], + inputSchema: { + type: 'object', + properties: { + id: { + 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: ['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, + commandLength: command.length, + sent: true, + }); + } catch (err) { + return errorResult('EXECUTION_ERROR', err); + } + }, + }, + + // ----- terminal_send_text ----- + { + name: 'terminal_send_text', + description: 'Type text into an IDE terminal without pressing Enter.', + riskLevel: 'shell', + profiles: ['interactive', 'full'], + inputSchema: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'Terminal session ID.', + }, + text: { + type: 'string', + description: 'Text to type. It is not logged or returned.', + }, + }, + required: ['id', 'text'], + }, + execute: async (params: Record) => { + const id = params.id as string; + const text = params.text as string; + if (!id || typeof text !== 'string') { + return errorResult('INVALID_INPUT', new Error('id and text are required')); + } + const terminalApi = tryGetService(container, ITerminalApiService); + if (!terminalApi) { + return serviceUnavailableResult('ITerminalApiService'); + } + try { + terminalApi.sendText(id, text, false); + return successResult({ terminalId: id, charCount: text.length, sent: true }); + } catch (err) { + return errorResult('EXECUTION_ERROR', err); + } + }, + }, + + // ----- terminal_send_control ----- + { + name: 'terminal_send_control', + description: 'Send an allowlisted control key to an IDE terminal.', + riskLevel: 'shell', + profiles: ['interactive', 'full'], + inputSchema: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'Terminal session ID.', + }, + key: { + type: 'string', + enum: ['enter', 'ctrl-c', 'ctrl-d', 'escape', 'tab', 'up', 'down', 'left', 'right'], + description: 'Control key to send.', + }, + }, + required: ['id', 'key'], + }, + execute: async (params: Record) => { + const id = params.id as string; + const key = params.key as string; + const sequence = typeof key === 'string' ? controlSequence(key) : undefined; + if (!id || !sequence) { + return errorResult('INVALID_INPUT', new Error('valid id and key are required')); + } + const terminalApi = tryGetService(container, ITerminalApiService); + if (!terminalApi) { + return serviceUnavailableResult('ITerminalApiService'); + } + try { + terminalApi.sendText(id, sequence, false); + return successResult({ terminalId: id, key, sent: true }); + } catch (err) { + return errorResult('EXECUTION_ERROR', err); + } + }, + }, + + // ----- terminal_run_command ----- + { + name: 'terminal_run_command', + description: 'Type a command into an IDE terminal and press Enter.', + riskLevel: 'shell', + profiles: ['interactive', 'full'], + inputSchema: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'Terminal session ID.', + }, + command: { + type: 'string', + description: 'Command text. It is not logged or returned.', + }, + }, + required: ['id', 'command'], + }, + execute: async (params: Record) => { + const id = params.id as string; + const command = params.command as string; + if (!id || !command) { + return errorResult('INVALID_INPUT', new Error('id and command are required')); + } + const terminalApi = tryGetService(container, ITerminalApiService); + if (!terminalApi) { + return serviceUnavailableResult('ITerminalApiService'); + } + try { + terminalApi.sendText(id, command, true); + return successResult({ terminalId: id, commandLength: command.length, sent: true }); + } catch (err) { + return errorResult('EXECUTION_ERROR', err); + } + }, + }, + + // ----- terminal_wait_for_pattern ----- + { + name: 'terminal_wait_for_pattern', + description: 'Wait until terminal output contains a string or regular expression.', + riskLevel: 'read', + profiles: ['default', 'interactive', 'full'], + inputSchema: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'Terminal session ID.', + }, + pattern: { + type: 'string', + description: 'String or regular expression to wait for.', + }, + useRegExp: { + type: 'boolean', + description: 'Whether pattern is a regular expression.', + }, + timeoutMs: { + type: 'number', + description: 'Timeout in milliseconds. Defaults to 10000, capped at 60000.', + }, + pollIntervalMs: { + type: 'number', + description: 'Polling interval in milliseconds. Defaults to 500.', + }, + }, + required: ['id', 'pattern'], + }, + execute: async (params: Record) => { + const id = params.id as string; + const pattern = params.pattern as string; + if (!id || !pattern) { + return errorResult('INVALID_INPUT', new Error('id and pattern are required')); + } + const terminalController = tryGetService(container, ITerminalController); + if (!terminalController) { + return serviceUnavailableResult('ITerminalController'); + } + try { + const client = getTerminalClient(terminalController, id); + if (!client) { + return errorResult('INVALID_INPUT', new Error('terminal not found')); + } + const timeoutMs = toPositiveCappedNumber(params.timeoutMs, 10_000, MAX_WAIT_TIMEOUT_MS); + const pollIntervalMs = toPositiveCappedNumber(params.pollIntervalMs, 500, 5_000); + const matcher = params.useRegExp ? new RegExp(pattern) : null; + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + const output = readTerminalBuffer(client, { maxLines: DEFAULT_TERMINAL_LINES }).lines.join('\n'); + const matched = matcher ? matcher.test(output) : output.includes(pattern); + if (matched) { + return successResult({ terminalId: id, matched: true, elapsedMs: Date.now() - startedAt }); + } + await wait(pollIntervalMs); + } + return successResult({ terminalId: id, matched: false, timedOut: true, elapsedMs: Date.now() - startedAt }); + } catch (err) { + return errorResult('EXECUTION_ERROR', err); + } + }, + }, + + // ----- terminal_show ----- + { + name: 'terminal_show', + description: + 'Show/focus a specific terminal session in the terminal panel. Use this to bring a terminal into view.', + riskLevel: 'ui', + inputSchema: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'The terminal session ID to show. Get valid IDs from 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); + } + }, + }, + + // ----- terminal_get_process_id ----- + { + name: 'terminal_get_process_id', + description: + 'Get the OS process ID (PID) of the shell process running in a terminal session. Returns null if the process has exited.', + riskLevel: 'read', + inputSchema: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'The terminal session ID. Get valid IDs from 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); + } + }, + }, + + // ----- terminal_dispose ----- + { + 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.', + riskLevel: 'destructive', + profiles: ['full'], + inputSchema: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'The terminal session ID to close. Get valid IDs from terminal_list.', + }, + }, + required: ['id'], + }, + execute: async (params: Record) => { + const id = params.id as string; + if (!id) { + return errorResult('INVALID_INPUT', new Error('id is required')); + } + const terminalController = tryGetService(container, ITerminalController); + if (!terminalController) { + return serviceUnavailableResult('ITerminalController'); + } + const terminalApi = tryGetService(container, ITerminalApiService); + if (!terminalApi) { + return serviceUnavailableResult('ITerminalApiService'); + } + try { + const client = getTerminalClient(terminalController, id); + if (!client) { + return errorResult('INVALID_INPUT', new Error('terminal not found')); + } + terminalApi.removeTerm(id); + return successResult({ terminalId: id, disposed: true }); + } catch (err) { + return errorResult('EXECUTION_ERROR', err); + } + }, + }, + + // ----- terminal_resize ----- + { + name: 'terminal_resize', + description: 'Resize a terminal session to the specified number of columns (width) and rows (height).', + riskLevel: 'ui', + inputSchema: { + type: 'object', + properties: { + id: { + 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: ['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); + } + }, + }, + + // ----- terminal_get_os ----- + { + name: 'terminal_get_os', + description: + 'Get the operating system type of the terminal backend (e.g. "Linux", "macOS", "Windows"). Useful for writing platform-specific commands.', + riskLevel: 'read', + 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); + } + }, + }, + + // ----- terminal_get_profiles ----- + { + name: 'terminal_get_profiles', + 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.', + riskLevel: 'read', + profiles: ['interactive', 'full'], + 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); + } + }, + }, + + // ----- terminal_show_panel ----- + { + name: 'terminal_show_panel', + description: + 'Show/open the terminal panel in the IDE. Use this to ensure the terminal panel is visible before interacting with terminals.', + riskLevel: 'ui', + 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-groups/workspace.webmcp-group.ts b/packages/ai-native/src/browser/acp/webmcp-groups/workspace.webmcp-group.ts new file mode 100644 index 0000000000..2483be2c95 --- /dev/null +++ b/packages/ai-native/src/browser/acp/webmcp-groups/workspace.webmcp-group.ts @@ -0,0 +1,117 @@ +/** + * WebMCP group definition for workspace-level IDE context. + */ +import { Injector } from '@opensumi/di'; +import { AppConfig } from '@opensumi/ide-core-browser'; +import { URI } from '@opensumi/ide-core-common'; +import { WorkbenchEditorService } from '@opensumi/ide-editor'; +import { IWorkspaceService } from '@opensumi/ide-workspace'; + +import { WebMcpGroupRegistration } from '../webmcp-group-registry'; +import { classifyError, errorResult, serviceUnavailableResult, successResult, tryGetService } from '../webmcp-utils'; + +function toFsPath(uri: string): string { + return URI.parse(uri).codeUri.fsPath; +} + +export function createWorkspaceGroup(container: Injector): WebMcpGroupRegistration { + return { + name: 'workspace', + description: 'Workspace context and open editor state', + defaultLoaded: true, + tools: [ + { + name: 'workspace_get_info', + description: + 'Get workspace metadata, including root folders, workspace name, multi-root state, and the configured workspace directory.', + riskLevel: 'read', + inputSchema: { + type: 'object', + properties: {}, + }, + execute: async () => { + const workspaceService = tryGetService(container, IWorkspaceService); + if (!workspaceService) { + return serviceUnavailableResult('IWorkspaceService'); + } + const appConfig = tryGetService(container, AppConfig); + try { + await workspaceService.whenReady; + const roots = workspaceService.tryGetRoots().map((root) => ({ + uri: root.uri, + path: toFsPath(root.uri), + name: workspaceService.getWorkspaceName(URI.parse(root.uri)), + })); + return successResult({ + workspaceDir: appConfig?.workspaceDir ?? null, + roots, + rootCount: roots.length, + isMultiRootWorkspaceOpened: workspaceService.isMultiRootWorkspaceOpened, + isMultiRootWorkspaceEnabled: workspaceService.isMultiRootWorkspaceEnabled, + }); + } catch (err) { + return errorResult(classifyError(err), err); + } + }, + }, + { + name: 'workspace_list_open_files', + description: + 'List files currently opened in editor groups. Use this to understand the user visible editing context.', + riskLevel: 'read', + inputSchema: { + type: 'object', + properties: {}, + }, + execute: async () => { + const editorService = tryGetService(container, WorkbenchEditorService); + if (!editorService) { + return serviceUnavailableResult('WorkbenchEditorService'); + } + try { + const activeUri = editorService.currentEditor?.currentUri?.toString(); + const files = editorService.editorGroups.flatMap((group, groupIndex) => + group.resources.map((resource) => ({ + uri: resource.uri.toString(), + path: resource.uri.codeUri.fsPath, + name: resource.name, + groupIndex, + active: resource.uri.toString() === activeUri, + })), + ); + return successResult({ files, total: files.length }); + } catch (err) { + return errorResult(classifyError(err), err); + } + }, + }, + { + name: 'workspace_list_recent_workspaces', + description: 'List recently used workspaces known to the IDE.', + riskLevel: 'read', + inputSchema: { + type: 'object', + properties: { + maxResults: { + type: 'number', + description: 'Maximum number of recent workspaces to return. Defaults to 10, capped at 50.', + }, + }, + }, + execute: async (params: Record) => { + const workspaceService = tryGetService(container, IWorkspaceService); + if (!workspaceService) { + return serviceUnavailableResult('IWorkspaceService'); + } + try { + const maxResults = Math.min(Math.max(Number(params.maxResults) || 10, 1), 50); + const workspaces = await workspaceService.getMostRecentlyUsedWorkspaces(); + return successResult({ workspaces: workspaces.slice(0, maxResults), total: workspaces.length }); + } catch (err) { + return errorResult(classifyError(err), err); + } + }, + }, + ], + }; +} diff --git a/packages/ai-native/src/browser/acp/webmcp-model-context-adapter.ts b/packages/ai-native/src/browser/acp/webmcp-model-context-adapter.ts new file mode 100644 index 0000000000..cb0b7b02bc --- /dev/null +++ b/packages/ai-native/src/browser/acp/webmcp-model-context-adapter.ts @@ -0,0 +1,86 @@ +import { ensureModelContext } from '@opensumi/ide-core-browser/lib/webmcp-polyfill'; + +import { canExposeWebMcpTool } from '../../common/webmcp-policy'; + +import type { WebMcpGroupDefinitionOptions, WebMcpGroupRegistry } from './webmcp-group-registry'; +import type { NavigatorModelContext, WebMCPTool } from '@opensumi/ide-core-browser/lib/webmcp-types'; +import type { IDisposable } from '@opensumi/ide-core-common'; + +export interface WebMcpModelContextAdapterOptions extends WebMcpGroupDefinitionOptions { + defaultLoadedOnly?: boolean; +} + +export interface WebMcpModelContextToolDefinition extends Omit { + group: string; +} + +const registeredModelContextToolNames = new WeakMap>(); + +export function getWebMcpModelContextToolDefinitions( + registry: WebMcpGroupRegistry, + options?: WebMcpModelContextAdapterOptions, +): WebMcpModelContextToolDefinition[] { + const { defaultLoadedOnly = true, includeAllTools = false } = options ?? {}; + + const definitions = registry + .getGroupDefinitions({ ...options, includeAllTools }) + .filter((group) => !defaultLoadedOnly || group.defaultLoaded) + .flatMap((group) => + group.tools + .filter((tool) => canExposeWebMcpTool(tool, group.profile ?? 'default')) + .map((tool) => ({ + group: group.name, + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema as WebMCPTool['inputSchema'], + })), + ); + + const seen = new Set(); + return definitions.filter((definition) => { + if (seen.has(definition.name)) { + return false; + } + seen.add(definition.name); + return true; + }); +} + +export function registerWebMcpModelContextTools( + registry: WebMcpGroupRegistry, + options?: WebMcpModelContextAdapterOptions, +): IDisposable { + ensureModelContext(); + + const modelContext = navigator.modelContext!; + const registeredToolNames = registeredModelContextToolNames.get(modelContext) ?? new Set(); + registeredModelContextToolNames.set(modelContext, registeredToolNames); + + modelContext.getTools?.().forEach((tool) => registeredToolNames.add(tool.name)); + + const registeredByThisCall: string[] = []; + const disposables = getWebMcpModelContextToolDefinitions(registry, options) + .filter((definition) => { + if (registeredToolNames.has(definition.name)) { + return false; + } + registeredToolNames.add(definition.name); + registeredByThisCall.push(definition.name); + return true; + }) + .map((definition) => + modelContext.registerTool({ + name: definition.name, + description: definition.description, + inputSchema: definition.inputSchema, + execute: (args: Record) => registry.executeTool(definition.group, definition.name, args ?? {}), + }), + ); + + return { + dispose: () => { + disposables.forEach((disposable) => disposable.dispose()); + registeredByThisCall.forEach((toolName) => registeredToolNames.delete(toolName)); + }, + }; +} 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..c2331c6a73 100644 --- a/packages/ai-native/src/browser/ai-core.contribution.ts +++ b/packages/ai-native/src/browser/ai-core.contribution.ts @@ -39,6 +39,8 @@ import { AI_INLINE_COMPLETION_REPORTER, AI_INLINE_COMPLETION_VISIBLE, AI_INLINE_DIFF_PARTIAL_EDIT, + AI_PANEL_LAYOUT_SET, + AI_PANEL_LAYOUT_TOGGLE, } from '@opensumi/ide-core-browser/lib/ai-native/command'; import { InlineChatIsVisible, @@ -48,11 +50,11 @@ import { InlineInputWidgetIsVisible, } from '@opensumi/ide-core-browser/lib/contextkey/ai-native'; import { DesignLayoutConfig } from '@opensumi/ide-core-browser/lib/layout/constants'; +import { IMenuRegistry, MenuContribution, MenuId } from '@opensumi/ide-core-browser/lib/menu/next'; import { IBrowserCtxMenu } from '@opensumi/ide-core-browser/lib/menu/next/renderer/ctxmenu/browser'; import { AI_NATIVE_SETTING_GROUP_TITLE, ChatFeatureRegistryToken, - ChatHistoryRegistryToken, ChatInputRegistryToken, ChatRenderRegistryToken, ChatServiceToken, @@ -62,6 +64,7 @@ import { InlineChatFeatureRegistryToken, IntelligentCompletionsRegistryToken, MCPConfigServiceToken, + PanelLayoutMode, PreferenceScope, ProblemFixRegistryToken, RenameCandidatesProviderRegistryToken, @@ -70,7 +73,7 @@ import { StorageProvider, TerminalRegistryToken, URI, - isUndefined, + WebMcpGroupRegistryToken, runWhenIdle, } from '@opensumi/ide-core-common'; import { DESIGN_MENU_BAR_RIGHT } from '@opensumi/ide-design'; @@ -111,18 +114,26 @@ import { MCP_SERVER_TYPE } from '../common/types'; import { AcpChatInput } from './acp/components/AcpChatInput'; import { AcpChatMentionInput } from './acp/components/AcpChatMentionInput'; +import { WebMcpGroupRegistry } from './acp/webmcp-group-registry'; +import { createAcpChatGroup } from './acp/webmcp-groups/acp-chat.webmcp-group'; +import { createDiagnosticsGroup } from './acp/webmcp-groups/diagnostics.webmcp-group'; +import { createEditorGroup } from './acp/webmcp-groups/editor.webmcp-group'; +import { createFileGroup } from './acp/webmcp-groups/file.webmcp-group'; +import { createOpenSumiMcpGroup } from './acp/webmcp-groups/opensumi-mcp.webmcp-group'; +import { createSearchGroup } from './acp/webmcp-groups/search.webmcp-group'; +import { createTerminalGroup } from './acp/webmcp-groups/terminal.webmcp-group'; +import { createWorkspaceGroup } from './acp/webmcp-groups/workspace.webmcp-group'; +import { registerWebMcpModelContextTools } from './acp/webmcp-model-context-adapter'; 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'; @@ -133,6 +144,7 @@ import { IntelligentCompletionsController } from './contrib/intelligent-completi import { ProblemFixController } from './contrib/problem-fix/problem-fix.controller'; import { RenameSingleHandler } from './contrib/rename/rename.handler'; import { AIRunToolbar } from './contrib/run-toolbar/run-toolbar'; +import { AIPanelLayoutService, AI_PANEL_LAYOUT_CONTEXT, AI_PANEL_LAYOUT_MENU } from './layout/panel-layout.service'; import { AIChatTabRenderer, AIChatTabRendererWithTab, @@ -185,6 +197,7 @@ const DynamicChatViewWrapper: React.FC = () => { KeybindingContribution, ComponentContribution, SlotRendererContribution, + MenuContribution, MonacoContribution, MultiDiffSourceContribution, ) @@ -197,6 +210,7 @@ export class AINativeBrowserContribution KeybindingContribution, ComponentContribution, SlotRendererContribution, + MenuContribution, MonacoContribution, MultiDiffSourceContribution { @@ -233,9 +247,6 @@ export class AINativeBrowserContribution @Autowired(ChatViewRegistryToken) private readonly chatViewRegistry: IChatViewRegistry; - @Autowired(ChatHistoryRegistryToken) - private readonly chatHistoryRegistry: IChatHistoryRegistry; - @Autowired(ResolveConflictRegistryToken) private readonly resolveConflictRegistry: IResolveConflictRegistry; @@ -257,6 +268,9 @@ export class AINativeBrowserContribution @Autowired(DesignLayoutConfig) private readonly designLayoutConfig: DesignLayoutConfig; + @Autowired(AIPanelLayoutService) + private readonly panelLayoutService: AIPanelLayoutService; + @Autowired(AICompletionsService) private readonly aiCompletionsService: AICompletionsService; @@ -329,6 +343,8 @@ export class AINativeBrowserContribution @Autowired() private readonly chatMultiDiffResolver: ChatMultiDiffResolver; + private webMcpModelContextDisposable: IDisposable | undefined; + constructor() { this.registerFeature(); } @@ -342,6 +358,8 @@ export class AINativeBrowserContribution } async initialize() { + this.panelLayoutService.initialize(); + const { supportsChatAssistant, supportsAgentMode } = this.aiNativeConfigService.capabilities; if (supportsChatAssistant) { @@ -421,6 +439,8 @@ export class AINativeBrowserContribution } onDidStart() { + this.registerWebMcpSurface(); + runWhenIdle(() => { const { supportsRenameSuggestions, supportsInlineChat, supportsMCP, supportsCustomLLMSettings } = this.aiNativeConfigService.capabilities; @@ -493,6 +513,29 @@ export class AINativeBrowserContribution }); } + onStop() { + this.webMcpModelContextDisposable?.dispose(); + } + + private registerWebMcpSurface() { + if (this.webMcpModelContextDisposable) { + return; + } + + // Register WebMCP groups once, then expose the same registry through + // navigator.modelContext and the Node-side HTTP MCP server. + const groupRegistry = this.injector.get(WebMcpGroupRegistryToken); + groupRegistry.registerGroup(createOpenSumiMcpGroup(this.injector)); + groupRegistry.registerGroup(createWorkspaceGroup(this.injector)); + groupRegistry.registerGroup(createSearchGroup(this.injector)); + groupRegistry.registerGroup(createDiagnosticsGroup(this.injector)); + groupRegistry.registerGroup(createFileGroup(this.injector)); + groupRegistry.registerGroup(createTerminalGroup(this.injector)); + groupRegistry.registerGroup(createEditorGroup(this.injector)); + groupRegistry.registerGroup(createAcpChatGroup(this.injector)); + this.webMcpModelContextDisposable = registerWebMcpModelContextTools(groupRegistry); + } + private async initMCPServers() { const storage = await this.storageProvider(STORAGE_NAMESPACE.CHAT); let disabledMCPServers = storage.get(MCPServersDisabledKey, []); @@ -545,7 +588,10 @@ export class AINativeBrowserContribution } const userServers = mcpServerFromWorkspace.value?.mcpServers; // 总是初始化内置服务器,根据禁用列表决定是否启用 - this.sumiMCPServerBackendProxy.$initBuiltinMCPServer(!disabledMCPServers.includes(BUILTIN_MCP_SERVER_NAME)); + const webMcpEnabled = this.preferenceService.get(AINativeSettingSectionsId.WebMcpEnabled, true); + this.sumiMCPServerBackendProxy.$initBuiltinMCPServer( + !disabledMCPServers.includes(BUILTIN_MCP_SERVER_NAME) && webMcpEnabled !== false, + ); if (userServers && Object.keys(userServers).length > 0) { const mcpServers = ( @@ -660,13 +706,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, @@ -688,6 +727,10 @@ export class AINativeBrowserContribution id: AINativeSettingSectionsId.ChatVisibleType, localized: 'preference.ai.native.chat.visible.type', }, + { + id: AINativeSettingSectionsId.PanelLayout, + localized: 'preference.ai.native.panelLayout', + }, ], }); @@ -821,6 +864,10 @@ export class AINativeBrowserContribution id: AINativeSettingSectionsId.DefaultAgentType, localized: 'preference.ai.native.agent.defaultType', }, + { + id: AINativeSettingSectionsId.AcpThreadPoolSize, + localized: 'preference.ai-native.acp.threadPoolSize', + }, ], }); } @@ -939,10 +986,22 @@ export class AINativeBrowserContribution commands.registerCommand(AI_CHAT_VISIBLE, { execute: (visible?: boolean) => { - this.layoutService.toggleSlot(AI_CHAT_VIEW_ID, isUndefined(visible) ? true : visible); + if (visible === false) { + this.layoutService.toggleSlot(AI_CHAT_VIEW_ID, false); + return; + } + this.panelLayoutService.showAIChatView(); }, }); + commands.registerCommand(AI_PANEL_LAYOUT_SET, { + execute: (mode: PanelLayoutMode) => this.panelLayoutService.setLayoutMode(mode), + }); + + commands.registerCommand(AI_PANEL_LAYOUT_TOGGLE, { + execute: () => this.panelLayoutService.toggleLayoutMode(), + }); + commands.registerCommand(AI_INLINE_COMPLETION_VISIBLE, { execute: async (visible: boolean) => { if (!visible) { @@ -967,6 +1026,32 @@ export class AINativeBrowserContribution }); } + registerMenus(menus: IMenuRegistry): void { + menus.registerMenuItem(MenuId.MenubarViewMenu, { + submenu: AI_PANEL_LAYOUT_MENU, + label: 'Panel Layout', + group: '5_panel', + }); + menus.registerMenuItem(AI_PANEL_LAYOUT_MENU, { + command: { + id: AI_PANEL_LAYOUT_SET.id, + label: 'Classic', + }, + group: 'navigation', + extraTailArgs: ['classic'], + toggledWhen: `${AI_PANEL_LAYOUT_CONTEXT} == classic`, + }); + menus.registerMenuItem(AI_PANEL_LAYOUT_MENU, { + command: { + id: AI_PANEL_LAYOUT_SET.id, + label: 'Agent', + }, + group: 'navigation', + extraTailArgs: ['agentic'], + toggledWhen: `${AI_PANEL_LAYOUT_CONTEXT} == agentic`, + }); + } + registerRenderer(registry: SlotRendererRegistry): void { const tabbarConfig: TabbarBehaviorConfig = { isLatter: true, 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..0a87cea970 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, @@ -10,7 +10,9 @@ import { IAIReporter, IApplicationService, IChatProgress, + IChatSessionState, 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'; @@ -26,9 +28,12 @@ import { IChatAgentResult, IChatAgentService, IChatAgentWelcomeMessage, + IChatManagerService, } from '../../common/index'; import { MCPConfigService } from '../mcp/config/mcp-config.service'; +import { ChatManagerService } from './chat-manager.service'; +import { AcpChatManagerService } from './chat-manager.service.acp'; import { ChatFeatureRegistry } from './chat.feature.registry'; /** @@ -68,6 +73,12 @@ export class AcpChatAgent implements IChatAgent { @Autowired(IACPConfigProvider) protected readonly configProvider: IACPConfigProvider; + @Autowired(ILogger) + protected readonly logger: ILogger; + + @Autowired(IChatManagerService) + protected readonly chatManagerService: ChatManagerService; + public id = AcpChatAgent.AGENT_ID; public get metadata(): IChatAgentMetadata { @@ -100,6 +111,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, @@ -120,6 +137,11 @@ export class AcpChatAgent implements IChatAgent { ): Promise { const chatDeferred = new Deferred(); const { message, command } = request; + this.logger.log( + `[ACP Chat] invoke start — rawSessionId=${request.sessionId}, requestId=${request.requestId}, command=${ + command || '(empty)' + }, messageChars=${message.length}, images=${request.images?.length ?? 0}, historyMessages=${history.length}`, + ); let prompt: string = message; if (command) { const commandHandler = this.chatFeatureRegistry.getSlashCommandHandler(command); @@ -127,6 +149,9 @@ export class AcpChatAgent implements IChatAgent { const editor = this.monacoCommandRegistry.getActiveCodeEditor(); const slashCommandPrompt = await commandHandler.providerPrompt(message, editor); prompt = slashCommandPrompt; + this.logger.log( + `[ACP Chat] invoke slash prompt resolved — requestId=${request.requestId}, command=${command}, promptChars=${prompt.length}`, + ); } } @@ -134,6 +159,9 @@ export class AcpChatAgent implements IChatAgent { if (command) { const commandHandler = this.chatFeatureRegistry.getSlashCommandHandler(command); if (commandHandler?.invoke) { + this.logger.log( + `[ACP Chat] invoke custom slash handler — requestId=${request.requestId}, command=${command}, promptChars=${prompt.length}`, + ); await commandHandler.invoke(prompt, progress, token); chatDeferred.resolve(); return {}; @@ -149,30 +177,75 @@ export class AcpChatAgent implements IChatAgent { } // agent 模式只需要发送最后一条数据 const lastmessage = history[history.length - 1]; + this.logger.log( + `[ACP Chat] invoke normalized — sessionId=${sessionId}, requestId=${request.requestId}, promptChars=${ + prompt.length + }, lastMessageRole=${lastmessage?.role ?? '(empty)'}`, + ); 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); + this.logger.log( + `[ACP Chat] requestStream opened — sessionId=${sessionId}, requestId=${request.requestId}, historyMessages=${requestOptions.history.length}`, ); + let streamDataCount = 0; + let hasLoggedFirstContent = false; listenReadable(stream, { onData: (data) => { - progress(data); + streamDataCount += 1; + const kind = data.kind; + if (data.kind === 'threadStatus') { + this.logger.log( + `[ACP Chat] stream data — sessionId=${sessionId}, requestId=${request.requestId}, kind=threadStatus, status=${data.threadStatus}`, + ); + this.handleThreadStatusUpdate(data.threadStatus, data.sessionId); + } else if (data.kind === 'sessionState') { + this.logger.log( + `[ACP Chat] stream data — sessionId=${sessionId}, requestId=${ + request.requestId + }, kind=sessionState, currentModeId=${data.currentModeId ?? '(empty)'}`, + ); + this.handleSessionStateUpdate(data, sessionId); + } else { + const shouldLogData = + !hasLoggedFirstContent || (kind !== 'content' && kind !== 'markdownContent' && kind !== 'reasoning'); + if (shouldLogData) { + this.logger.log( + `[ACP Chat] stream data — sessionId=${sessionId}, requestId=${request.requestId}, kind=${kind}, count=${streamDataCount}`, + ); + hasLoggedFirstContent = true; + } + progress(data); + } }, onEnd: () => { + this.logger.log( + `[ACP Chat] stream end — sessionId=${sessionId}, requestId=${request.requestId}, dataCount=${streamDataCount}`, + ); chatDeferred.resolve(); }, onError: (error) => { + this.logger.error( + `[ACP Chat] stream error — sessionId=${sessionId}, requestId=${request.requestId}, error=${error.message}`, + ); this.messageService.error(error.message); this.aiReporter.end(sessionId + '_' + request.requestId, { message: error.message, @@ -185,12 +258,37 @@ export class AcpChatAgent implements IChatAgent { await chatDeferred.promise; } catch (e) { - this.messageService.error(e.message); - chatDeferred.reject(e); + const message = e instanceof Error ? e.message : String(e); + this.logger.error( + `[ACP Chat] invoke error — sessionId=${sessionId}, requestId=${request.requestId}, error=${message}`, + ); + this.messageService.error(message); + return { + errorDetails: { message }, + }; } 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); + } + } + + private handleSessionStateUpdate(state: IChatSessionState, fallbackSessionId: string): void { + const manager = this.chatManagerService as AcpChatManagerService; + manager.applySessionStateUpdate?.(state.sessionId || fallbackSessionId, { + currentModeId: state.currentModeId, + currentModelId: state.currentModelId, + configOptions: state.configOptions, + }); + } + async provideSlashCommands(): Promise { return this.chatFeatureRegistry .getAllSlashCommand() diff --git a/packages/ai-native/src/browser/chat/acp-session-provider.ts b/packages/ai-native/src/browser/chat/acp-session-provider.ts index 9e71afdd0a..19c322ce1f 100644 --- a/packages/ai-native/src/browser/chat/acp-session-provider.ts +++ b/packages/ai-native/src/browser/chat/acp-session-provider.ts @@ -26,6 +26,10 @@ export class ACPSessionProvider implements ISessionProvider { private loadedSessionsResult: ISessionModel[] | null = null; + private loadingSessionsPromise: Promise | null = null; + + private didRetryEmptySessionsResult = false; + canHandle(mode: string): boolean { return mode.startsWith('acp'); } @@ -37,7 +41,7 @@ export class ACPSessionProvider implements ISessionProvider { try { const config = await this.configProvider.resolveConfig(); - const result = await this.aiBackService.createSession(config); + const result = (await this.aiBackService.createSession(config)) as any; if (!result?.sessionId) { throw new Error('createSession did not return a valid sessionId'); @@ -45,10 +49,17 @@ export class ACPSessionProvider implements ISessionProvider { // 构造本地 Session ID(添加 acp: 前缀) const sessionId = `acp:${result.sessionId}`; + const createdAt = Date.now(); // 构造空壳会话模型 const sessionModel: ISessionModel & { extension?: ISessionModelExtension } = { sessionId, + createdAt, + modelId: result.currentModelId, + agentModes: result.modes, + currentModeId: result.currentModeId, + agentModels: result.models, + configOptions: result.configOptions, history: { additional: {}, messages: [], @@ -69,12 +80,26 @@ export class ACPSessionProvider implements ISessionProvider { } async loadSessions(): Promise { - if (this.loadedSessionsResult) { + if (Array.isArray(this.loadedSessionsResult)) { return this.loadedSessionsResult; } + if (this.loadingSessionsPromise) { + return this.loadingSessionsPromise; + } + + this.loadingSessionsPromise = this.doLoadSessions(); + try { + return await this.loadingSessionsPromise; + } finally { + this.loadingSessionsPromise = null; + } + } + + private async doLoadSessions(): Promise { if (!this.aiBackService?.listSessions) { - return []; + this.loadedSessionsResult = []; + return this.loadedSessionsResult; } try { @@ -82,7 +107,14 @@ export class ACPSessionProvider implements ISessionProvider { const result = await this.aiBackService!.listSessions(config); if (!result?.sessions?.length) { - return []; + // The Agentic shell may ask for history before the ACP process has a thread. + // Leave the first empty result retryable, then cache a confirmed empty history. + if (!this.didRetryEmptySessionsResult) { + this.didRetryEmptySessionsResult = true; + return []; + } + this.loadedSessionsResult = []; + return this.loadedSessionsResult; } // 只返回会话列表的元数据,不加载完整数据 @@ -101,12 +133,10 @@ export class ACPSessionProvider implements ISessionProvider { title: sessionMeta.title, })); - if (sessionModels.length === 0) { - return []; - } this.loadedSessionsResult = sessionModels as unknown as ISessionModel[]; + this.didRetryEmptySessionsResult = false; - return this.loadedSessionsResult ?? []; + return this.loadedSessionsResult; } catch (e) { this.messageService.error(e.message); return []; @@ -127,7 +157,7 @@ export class ACPSessionProvider implements ISessionProvider { try { const config = await this.configProvider.resolveConfig(); - const agentSession = await this.aiBackService.loadAgentSession(config, agentSessionId); + const agentSession = (await this.aiBackService.loadAgentSession(config, agentSessionId)) as any; if (!agentSession) { return undefined; @@ -150,6 +180,11 @@ export class ACPSessionProvider implements ISessionProvider { sessionId: string, agentSession: { sessionId: string; + modes?: ISessionModel['agentModes']; + currentModeId?: string; + models?: ISessionModel['agentModels']; + currentModelId?: string; + configOptions?: ISessionModel['configOptions']; messages: Array<{ role: 'user' | 'assistant'; content: string; @@ -177,6 +212,12 @@ export class ACPSessionProvider implements ISessionProvider { const result = { sessionId, + createdAt: messages[0]?.timestamp, + modelId: agentSession.currentModelId, + agentModes: agentSession.modes, + currentModeId: agentSession.currentModeId, + agentModels: agentSession.models, + configOptions: agentSession.configOptions, history: { additional: {}, messages, diff --git a/packages/ai-native/src/browser/chat/chat-agent.service.ts b/packages/ai-native/src/browser/chat/chat-agent.service.ts index 8ef8b25e32..5f546f2b51 100644 --- a/packages/ai-native/src/browser/chat/chat-agent.service.ts +++ b/packages/ai-native/src/browser/chat/chat-agent.service.ts @@ -143,28 +143,77 @@ export class ChatAgentService extends Disposable implements IChatAgentService { history: CoreMessage[], token: CancellationToken, ): Promise { + const invokeStartTime = Date.now(); + this.logger.log( + `[ChatAgentService] invokeAgent start — agentId=${id}, sessionId=${request.sessionId}, requestId=${ + request.requestId + }, messageChars=${request.message.length}, historyMessages=${history.length}, regenerate=${Boolean( + request.regenerate, + )}`, + ); const data = this.agents.get(id); if (!data) { + this.logger.error( + `[ChatAgentService] invokeAgent missing agent — agentId=${id}, sessionId=${request.sessionId}, requestId=${ + request.requestId + }, registeredAgents=${Array.from(this.agents.keys()).join(',') || '(empty)'}`, + ); throw new Error(`No agent with id ${id},this.agents ${this.agents}`); } // 发送第一条消息时携带初始 context if (!this.initialUserMessageMap.has(request.sessionId)) { + this.logger.log( + `[ChatAgentService] invokeAgent context enhance initial — agentId=${id}, sessionId=${request.sessionId}, requestId=${request.requestId}`, + ); this.initialUserMessageMap.set(request.sessionId, request.message); const rawMessage = request.message; request.message = await this.provideContextMessage(rawMessage, request.sessionId); } else if (this.shouldUpdateContext || request.regenerate || history.length === 0) { + this.logger.log( + `[ChatAgentService] invokeAgent context enhance refresh — agentId=${id}, sessionId=${ + request.sessionId + }, requestId=${request.requestId}, shouldUpdateContext=${this.shouldUpdateContext}, regenerate=${Boolean( + request.regenerate, + )}, historyMessages=${history.length}`, + ); request.message = await this.provideContextMessage(request.message, request.sessionId); this.shouldUpdateContext = false; + } else { + this.logger.log( + `[ChatAgentService] invokeAgent context enhance skipped — agentId=${id}, sessionId=${request.sessionId}, requestId=${request.requestId}`, + ); } + this.logger.log( + `[ChatAgentService] invokeAgent calling agent — agentId=${id}, sessionId=${request.sessionId}, requestId=${request.requestId}, enhancedMessageChars=${request.message.length}`, + ); const result = await data.agent.invoke(request, progress, history, token); + this.logger.log( + `[ChatAgentService] invokeAgent done — agentId=${id}, sessionId=${request.sessionId}, requestId=${ + request.requestId + }, elapsedMs=${Date.now() - invokeStartTime}`, + ); return result; } private async provideContextMessage(message: string, sessionId: string) { + const startTime = Date.now(); + this.logger.log( + `[ChatAgentService] provideContextMessage serialize start — sessionId=${sessionId}, messageChars=${message.length}`, + ); const context = await this.llmContextService.serialize(); + this.logger.log( + `[ChatAgentService] provideContextMessage serialize done — sessionId=${sessionId}, elapsedMs=${ + Date.now() - startTime + }, contextChars=${JSON.stringify(context).length}`, + ); const fullMessage = await this.promptProvider.provideContextPrompt(context, message); + this.logger.log( + `[ChatAgentService] provideContextMessage prompt done — sessionId=${sessionId}, elapsedMs=${ + Date.now() - startTime + }, fullMessageChars=${fullMessage.length}`, + ); this.aiReporter.send({ msgType: AIServiceType.Chat, actionType: ActionTypeEnum.ContextEnhance, @@ -172,6 +221,11 @@ export class ChatAgentService extends Disposable implements IChatAgentService { sessionId, message: fullMessage, }); + this.logger.log( + `[ChatAgentService] provideContextMessage reporter sent — sessionId=${sessionId}, elapsedMs=${ + Date.now() - startTime + }`, + ); return fullMessage; } 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..f948657f13 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 @@ -1,29 +1,67 @@ import { Autowired, Injectable } from '@opensumi/di'; -import { AINativeConfigService } from '@opensumi/ide-core-browser'; -import { AvailableCommand, debounce } from '@opensumi/ide-core-common'; +import { AINativeConfigService, ILogger } from '@opensumi/ide-core-browser'; +import { + AvailableCommand, + ChatMessageRole, + Emitter, + IChatSessionState, + IStorage, + STORAGE_NAMESPACE, + StorageProvider, + debounce, +} from '@opensumi/ide-core-common'; +import { cleanAttachedTextWrapper } from '../../common/utils'; import { MsgHistoryManager } from '../model/msg-history-manager'; import { ChatManagerService } from './chat-manager.service'; import { ChatModel, ChatRequestModel, ChatResponseModel } from './chat-model'; -import { ChatFeatureRegistry } from './chat.feature.registry'; import { ISessionModel, ISessionProvider } from './session-provider'; import { ISessionProviderRegistry } from './session-provider-registry'; const MAX_SESSION_COUNT = 20; +const MAX_TITLE_LENGTH = 100; +const DEFAULT_ACP_SESSION_TITLE = 'New Session'; +const ACP_SESSION_DISPLAY_TITLE_OVERRIDES_KEY = 'acpSessionDisplayTitleOverrides'; +const ACP_PROMPT_TITLE_PREFIXES = [ + 'OpenSumi exposes IDE capabilities', + "The user's OS version", + 'The rules section has', + 'For requests to create an OpenSumi IDE', +]; + +export interface AcpSessionStateChangeEvent { + sessionId: string; + model: ChatModel; + previousModeId?: string; + currentModeId?: string; +} @Injectable() export class AcpChatManagerService extends ChatManagerService { @Autowired(AINativeConfigService) protected readonly aiNativeConfig: AINativeConfigService; + @Autowired(ILogger) + protected readonly logger: ILogger; + @Autowired(ISessionProviderRegistry) private sessionProviderRegistry: ISessionProviderRegistry; + @Autowired(StorageProvider) + private readonly acpStorageProvider: StorageProvider; + private mainProvider: ISessionProvider | null = null; private availableCommands: AvailableCommand[] = []; + private acpTitleStorage: IStorage | undefined; + + private acpSessionDisplayTitleOverrides: Record = {}; + + private readonly onDidApplySessionStateEmitter = this.registerDispose(new Emitter()); + public readonly onDidApplySessionState = this.onDidApplySessionStateEmitter.event; + constructor() { super(); const mode = this.aiNativeConfig.capabilities.supportsAgentMode ? 'acp' : 'local'; @@ -33,9 +71,193 @@ export class AcpChatManagerService extends ChatManagerService { } override async init() { + await this.initDisplayTitleOverrides(); await this.loadSessionList(); } + private async initDisplayTitleOverrides(): Promise { + try { + this.acpTitleStorage = await this.acpStorageProvider(STORAGE_NAMESPACE.CHAT); + this.acpSessionDisplayTitleOverrides = + this.acpTitleStorage.get>(ACP_SESSION_DISPLAY_TITLE_OVERRIDES_KEY, {}) || {}; + } catch { + this.acpTitleStorage = undefined; + this.acpSessionDisplayTitleOverrides = {}; + } + } + + private persistDisplayTitleOverrides(): void { + this.acpTitleStorage?.set(ACP_SESSION_DISPLAY_TITLE_OVERRIDES_KEY, this.acpSessionDisplayTitleOverrides); + } + + private getDisplayTitleOverride(sessionId: string): string | undefined { + return this.acpSessionDisplayTitleOverrides[sessionId]; + } + + private peekSession(sessionId: string): ChatModel | undefined { + const sessionModels = this.sessionModels as typeof this.sessionModels & { + peek?: (key: string) => ChatModel | undefined; + }; + + return sessionModels.peek ? sessionModels.peek(sessionId) : sessionModels.get(sessionId); + } + + override getSession(sessionId: string): ChatModel | undefined { + if (this.aiNativeConfig.capabilities.supportsAgentMode) { + return this.peekSession(sessionId); + } + + return super.getSession(sessionId); + } + + private setSessionPreservingOrder(sessionId: string, session: ChatModel): void { + const sessionModels = this.sessionModels as typeof this.sessionModels & { + keys?: () => Iterable; + }; + const sessionIds = + sessionModels.has(sessionId) && sessionModels.keys ? Array.from(sessionModels.keys()) : undefined; + + this.sessionModels.set(sessionId, session); + + if (!sessionIds) { + return; + } + + const orderedSessions = sessionIds + .map((id) => [id, id === sessionId ? session : this.peekSession(id)] as const) + .filter((item): item is readonly [string, ChatModel] => Boolean(item[1])); + + this.sessionModels.clear(); + orderedSessions.forEach(([id, model]) => { + this.sessionModels.set(id, model); + }); + } + + private setDisplayTitleOverride(sessionId: string, title: string): void { + const displayTitle = this.createDisplayTitle(title); + if (!displayTitle) { + return; + } + + this.acpSessionDisplayTitleOverrides = { + ...this.acpSessionDisplayTitleOverrides, + [sessionId]: displayTitle, + }; + this.peekSession(sessionId)?.setTitle(displayTitle); + this.persistDisplayTitleOverrides(); + } + + private removeDisplayTitleOverride(sessionId: string): void { + if (!this.acpSessionDisplayTitleOverrides[sessionId]) { + return; + } + + const nextOverrides = { ...this.acpSessionDisplayTitleOverrides }; + delete nextOverrides[sessionId]; + this.acpSessionDisplayTitleOverrides = nextOverrides; + this.persistDisplayTitleOverrides(); + } + + private extractUserMessageFromAcpPrompt(text: string): string | undefined { + const match = text.match(/(?:^|\n)\s*---\s*(?:\n+|\s+)([\s\S]*)$/); + const userMessage = match?.[1]?.trim(); + return userMessage || undefined; + } + + private createDisplayTitle(text: string | undefined): string { + if (!text) { + return ''; + } + + const userMessage = this.extractUserMessageFromAcpPrompt(text) || text; + return cleanAttachedTextWrapper(userMessage).trim().slice(0, MAX_TITLE_LENGTH); + } + + private isLikelyAcpContextTitle(title: string | undefined): boolean { + if (!title) { + return false; + } + + return ACP_PROMPT_TITLE_PREFIXES.some((prefix) => title.trim().startsWith(prefix)); + } + + private createFallbackSessionTitle(sessionId: string): string { + const rawSessionId = sessionId.startsWith('acp:') ? sessionId.slice(4) : sessionId; + return `Session ${rawSessionId.slice(0, 8)}`; + } + + private resolveTitleFromMessages(item: ISessionModel): string { + const firstUserMessage = + item.history.messages.find((message) => message.role === ChatMessageRole.User) || item.history.messages[0]; + + return this.createDisplayTitle(firstUserMessage?.content); + } + + private resolveAcpSessionTitle(item: ISessionModel): string { + const overrideTitle = this.getDisplayTitleOverride(item.sessionId); + if (overrideTitle) { + return overrideTitle; + } + + const extractedTitle = this.extractUserMessageFromAcpPrompt(item.title || ''); + if (extractedTitle) { + return this.createDisplayTitle(extractedTitle); + } + + const title = this.createDisplayTitle(item.title); + if (title && !this.isLikelyAcpContextTitle(item.title)) { + return title; + } + + const messageTitle = this.resolveTitleFromMessages(item); + if (messageTitle) { + return messageTitle; + } + + if (item.title && this.isLikelyAcpContextTitle(item.title)) { + return this.createFallbackSessionTitle(item.sessionId); + } + + return DEFAULT_ACP_SESSION_TITLE; + } + + private getExistingTitleForLoadedSession( + sessionId: string, + existingSession: ChatModel | undefined, + ): string | undefined { + const overrideTitle = this.getDisplayTitleOverride(sessionId); + if (overrideTitle) { + return overrideTitle; + } + + const existingTitle = existingSession?.title; + if (existingTitle && !this.isLikelyAcpContextTitle(existingTitle)) { + return existingTitle; + } + + return undefined; + } + + private isEmptyDefaultSession(model: ChatModel): boolean { + return ( + model.title === DEFAULT_ACP_SESSION_TITLE && + model.history.getMessages().length === 0 && + model.requests.length === 0 + ); + } + + private moveEmptyDefaultSessionsToEnd(sessionIds: Set): void { + sessionIds.forEach((sessionId) => { + const session = this.peekSession(sessionId); + if (!session || !this.isEmptyDefaultSession(session)) { + return; + } + + this.sessionModels.delete(sessionId); + this.sessionModels.set(sessionId, session); + }); + } + async loadSessionList() { if (!this.mainProvider) { await this.storageInitEmitter.fireAndAwait(); @@ -56,6 +278,7 @@ export class AcpChatManagerService extends ChatManagerService { this.sessionModels.set(session.sessionId, session); }); } + this.moveEmptyDefaultSessionsToEnd(activeKeys); } catch (error) { this.sessionModels.clear(); } @@ -94,7 +317,7 @@ export class AcpChatManagerService extends ChatManagerService { async loadSession(sessionId: string) { if (this.aiNativeConfig.capabilities.supportsAgentMode) { - const existingSession = this.sessionModels.get(sessionId); + const existingSession = this.peekSession(sessionId); if (existingSession?.history?.getMessages()?.length) { return; } @@ -102,11 +325,27 @@ export class AcpChatManagerService extends ChatManagerService { if (this.mainProvider?.loadSession && sessionId) { return this.mainProvider.loadSession(sessionId).then((sessionData) => { if (sessionData) { - const sessions = this.fromAcpJSON([sessionData]); + const existingTitle = this.getExistingTitleForLoadedSession(sessionId, existingSession); + const sessionDataWithTitle = + existingTitle && (!sessionData.title || this.isLikelyAcpContextTitle(sessionData.title)) + ? { + ...sessionData, + title: existingTitle, + } + : sessionData; + const sessions = this.fromAcpJSON([sessionDataWithTitle]); if (sessions.length > 0) { const session = sessions[0]; - this.sessionModels.set(sessionId, session); + this.setSessionPreservingOrder(sessionId, session); this.listenSession(session); + if ( + !existingSession && + session.title && + session.title !== DEFAULT_ACP_SESSION_TITLE && + !this.isLikelyAcpContextTitle(session.title) + ) { + this.setDisplayTitleOverride(sessionId, session.title); + } } } }); @@ -114,6 +353,100 @@ export class AcpChatManagerService extends ChatManagerService { } } + override createRequest(sessionId: string, message: string, agentId: string, command?: string, images?: string[]) { + const model = this.getSession(sessionId); + const shouldSetDisplayTitle = + this.aiNativeConfig.capabilities.supportsAgentMode && + !this.getDisplayTitleOverride(sessionId) && + model && + ((model.history.getMessages().length === 0 && model.requests.length === 0) || + this.isLikelyAcpContextTitle(model.title)); + + this.logger.log( + `[ACP Chat][Manager] createRequest start — sessionId=${sessionId}, agentId=${agentId || '(empty)'}, command=${ + command || '(empty)' + }, messageChars=${message.length}, images=${images?.length ?? 0}, existingRequests=${ + model?.requests.length ?? 0 + }, historyMessages=${model?.history.getMessages().length ?? 0}`, + ); + + const request = super.createRequest(sessionId, message, agentId, command, images); + this.logger.log( + `[ACP Chat][Manager] createRequest ${request ? 'done' : 'skipped'} — sessionId=${sessionId}, requestId=${ + request?.requestId ?? '(empty)' + }`, + ); + if (request && shouldSetDisplayTitle) { + this.setDisplayTitleOverride(sessionId, message); + } + + return request; + } + + override async sendRequest(sessionId: string, request: ChatRequestModel, regenerate: boolean): Promise { + this.logger.log( + `[ACP Chat][Manager] sendRequest start — sessionId=${sessionId}, requestId=${ + request.requestId + }, regenerate=${regenerate}, agentId=${request.message.agentId}, command=${ + request.message.command || '(empty)' + }, messageChars=${request.message.prompt.length}, images=${request.message.images?.length ?? 0}`, + ); + try { + await super.sendRequest(sessionId, request, regenerate); + this.logger.log(`[ACP Chat][Manager] sendRequest done — sessionId=${sessionId}, requestId=${request.requestId}`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.logger.error( + `[ACP Chat][Manager] sendRequest error — sessionId=${sessionId}, requestId=${request.requestId}, error=${message}`, + ); + throw error; + } + } + + protected override shouldValidateModelChange(sessionId: string): boolean { + return !sessionId.startsWith('acp:'); + } + + override clearSession(sessionId: string): void { + super.clearSession(sessionId); + this.removeDisplayTitleOverride(sessionId); + } + + applySessionStateUpdate(sessionId: string, state: Partial>): void { + const lookupKey = sessionId.startsWith('acp:') ? sessionId : `acp:${sessionId}`; + const model = this.getSession(lookupKey); + if (!model) { + return; + } + + const previousModeId = model.currentModeId; + let changed = false; + + if (state.currentModeId !== undefined && model.currentModeId !== state.currentModeId) { + model.currentModeId = state.currentModeId; + changed = true; + } + if (state.currentModelId !== undefined && model.modelId !== state.currentModelId) { + model.modelId = state.currentModelId; + changed = true; + } + if (state.configOptions !== undefined) { + model.configOptions = state.configOptions; + changed = true; + } + + if (!changed) { + return; + } + + this.onDidApplySessionStateEmitter.fire({ + sessionId: lookupKey, + model, + previousModeId, + currentModeId: model.currentModeId, + }); + } + fallbackToLocal(): void { const localProvider = this.sessionProviderRegistry.getProvider('local'); if (!localProvider) { @@ -127,6 +460,7 @@ export class AcpChatManagerService extends ChatManagerService { private toSessionData(model: ChatModel): ISessionModel { return { sessionId: model.sessionId, + createdAt: model.createdAt, modelId: model.modelId, history: model.history.toJSON(), title: model.title, @@ -151,9 +485,14 @@ export class AcpChatManagerService extends ChatManagerService { .map((item) => { const model = new ChatModel(this.chatFeatureRegistry, { sessionId: item.sessionId, + createdAt: item.createdAt, history: new MsgHistoryManager(this.chatFeatureRegistry, item.history), modelId: item.modelId, - title: item?.title, + title: this.resolveAcpSessionTitle(item), + agentModes: item.agentModes, + currentModeId: item.currentModeId, + agentModels: item.agentModels, + configOptions: item.configOptions, }); const requests = item.requests.map( (request) => diff --git a/packages/ai-native/src/browser/chat/chat-manager.service.ts b/packages/ai-native/src/browser/chat/chat-manager.service.ts index e63009aa1a..4bb470815d 100644 --- a/packages/ai-native/src/browser/chat/chat-manager.service.ts +++ b/packages/ai-native/src/browser/chat/chat-manager.service.ts @@ -9,6 +9,7 @@ import { Emitter, IChatProgress, IDisposable, + ILogger, IStorage, LRUCache, STORAGE_NAMESPACE, @@ -25,6 +26,7 @@ import { ChatFeatureRegistry } from './chat.feature.registry'; interface ISessionModel { sessionId: string; + createdAt?: number; modelId: string; history: { additional: Record; messages: IHistoryChatMessage[] }; requests: { @@ -43,6 +45,10 @@ interface ISessionModel { const MAX_SESSION_COUNT = 20; +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + class DisposableLRUCache extends LRUCache implements IDisposable { disposeKey(key: K): void { const disposable = this.get(key); @@ -74,6 +80,9 @@ export class ChatManagerService extends Disposable { @Autowired(IChatAgentService) chatAgentService: IChatAgentService; + @Autowired(ILogger) + protected readonly logger: ILogger; + @Autowired(StorageProvider) private storageProvider: StorageProvider; @@ -91,6 +100,7 @@ export class ChatManagerService extends Disposable { .map((item) => { const model = new ChatModel(this.chatFeatureRegistry, { sessionId: item.sessionId, + createdAt: item.createdAt, history: new MsgHistoryManager(this.chatFeatureRegistry, item.history), modelId: item.modelId, }); @@ -171,30 +181,72 @@ export class ChatManagerService extends Disposable { } async sendRequest(sessionId: string, request: ChatRequestModel, regenerate: boolean) { + const startTime = Date.now(); + this.logger.log( + `[ChatManagerService] sendRequest enter — sessionId=${sessionId}, requestId=${request.requestId}, agentId=${ + request.message.agentId + }, command=${request.message.command || '(empty)'}, regenerate=${Boolean(regenerate)}`, + ); const model = this.getSession(sessionId); if (!model) { + this.logger.error( + `[ChatManagerService] sendRequest missing model — sessionId=${sessionId}, requestId=${request.requestId}`, + ); throw new Error(`Unknown session: ${sessionId}`); } + this.logger.log( + `[ChatManagerService] sendRequest model resolved — sessionId=${sessionId}, requestId=${ + request.requestId + }, requests=${model.requests.length}, historyMessages=${model.history.getMessages().length}`, + ); const savedModelId = model.modelId; const modelId = this.preferenceService.get(AINativeSettingSectionsId.ModelID); + this.logger.log( + `[ChatManagerService] sendRequest model preference — sessionId=${sessionId}, requestId=${ + request.requestId + }, savedModelId=${savedModelId || '(empty)'}, currentModelId=${modelId || '(empty)'}`, + ); if (!savedModelId) { // 首次对话时记录 modelId model.modelId = modelId; - } else if (savedModelId !== modelId) { + } else if (savedModelId !== modelId && this.shouldValidateModelChange(sessionId, model)) { // 模型切换时,清空对话历史 + this.logger.error( + `[ChatManagerService] sendRequest model changed — sessionId=${sessionId}, requestId=${ + request.requestId + }, savedModelId=${savedModelId || '(empty)'}, currentModelId=${modelId || '(empty)'}`, + ); throw new Error('Model changed unexpectedly'); + } else if (savedModelId !== modelId) { + this.logger.log( + `[ChatManagerService] sendRequest model change allowed — sessionId=${sessionId}, requestId=${ + request.requestId + }, savedModelId=${savedModelId || '(empty)'}, currentModelId=${modelId || '(empty)'}`, + ); } const source = new CancellationTokenSource(); const token = source.token; this.#pendingRequests.set(model.sessionId, source); + this.logger.log( + `[ChatManagerService] sendRequest pending registered — sessionId=${sessionId}, requestId=${request.requestId}`, + ); const listener = token.onCancellationRequested(() => { + this.logger.log( + `[ChatManagerService] sendRequest cancellation requested — sessionId=${sessionId}, requestId=${request.requestId}`, + ); request.response.cancel(); }); const contextWindow = this.preferenceService.get(AINativeSettingSectionsId.ContextWindow); + this.logger.log( + `[ChatManagerService] sendRequest history start — sessionId=${sessionId}, requestId=${request.requestId}, contextWindow=${contextWindow}`, + ); const history = model.getMessageHistory(contextWindow); + this.logger.log( + `[ChatManagerService] sendRequest history done — sessionId=${sessionId}, requestId=${request.requestId}, historyMessages=${history.length}`, + ); try { const progressCallback = (progress: IChatProgress) => { @@ -203,6 +255,9 @@ export class ChatManagerService extends Disposable { } model.acceptResponseProgress(request, progress); }; + this.logger.log( + `[ChatManagerService] sendRequest progress callback ready — sessionId=${sessionId}, requestId=${request.requestId}`, + ); const requestProps = { sessionId, requestId: request.requestId, @@ -211,6 +266,13 @@ export class ChatManagerService extends Disposable { images: request.message.images, regenerate, }; + this.logger.log( + `[ChatManagerService] sendRequest invokeAgent before — sessionId=${sessionId}, requestId=${ + request.requestId + }, agentId=${request.message.agentId}, messageChars=${requestProps.message.length}, historyMessages=${ + history.length + }, chatAgentService=${this.chatAgentService?.constructor?.name || '(unknown)'}`, + ); const result = await this.chatAgentService.invokeAgent( request.message.agentId, requestProps, @@ -218,10 +280,17 @@ export class ChatManagerService extends Disposable { history, token, ); + this.logger.log( + `[ChatManagerService] sendRequest invokeAgent after — sessionId=${sessionId}, requestId=${ + request.requestId + }, elapsedMs=${Date.now() - startTime}, hasErrorDetails=${Boolean(result.errorDetails)}`, + ); if (!token.isCancellationRequested) { if (result.errorDetails) { request.response.setErrorDetails(result.errorDetails); + request.response.complete(); + return; } const followups = this.chatAgentService.getFollowups( request.message.agentId, @@ -233,13 +302,33 @@ export class ChatManagerService extends Disposable { request.response.complete(); }); } + } catch (error) { + const message = getErrorMessage(error); + this.logger.error( + `[ChatManagerService] sendRequest error — sessionId=${sessionId}, requestId=${request.requestId}, error=${message}`, + ); + if (!token.isCancellationRequested) { + request.response.setErrorDetails({ message }); + request.response.complete(); + } } finally { + this.logger.log( + `[ChatManagerService] sendRequest cleanup — sessionId=${sessionId}, requestId=${request.requestId}, elapsedMs=${ + Date.now() - startTime + }, canceled=${token.isCancellationRequested}`, + ); listener.dispose(); this.#pendingRequests.disposeKey(model.sessionId); this.saveSessions(); } } + protected shouldValidateModelChange(_sessionId: string, _model: ChatModel): boolean { + void _sessionId; + void _model; + return true; + } + protected listenSession(session: ChatModel) { this.addDispose( session.history.onMessageAdditionalChange(() => { diff --git a/packages/ai-native/src/browser/chat/chat-model.ts b/packages/ai-native/src/browser/chat/chat-model.ts index df311d1fa3..0aa4e974e9 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'; @@ -30,6 +33,8 @@ import { IChatSlashCommandItem } from '../types'; import { ChatFeatureRegistry } from './chat.feature.registry'; +import type { AcpSessionConfigOption, AcpSessionModeOption, AcpSessionModelOption } from './session-provider'; + export type IChatProgressResponseContent = | IChatMarkdownContent | IChatAsyncContent @@ -300,13 +305,28 @@ export class ChatModel extends Disposable implements IChatModel { constructor( private chatFeatureRegistry: ChatFeatureRegistry, - initParams?: { sessionId?: string; history?: MsgHistoryManager; modelId?: string; title?: string }, + initParams?: { + sessionId?: string; + createdAt?: number; + history?: MsgHistoryManager; + modelId?: string; + title?: string; + agentModes?: AcpSessionModeOption[]; + currentModeId?: string; + agentModels?: AcpSessionModelOption[]; + configOptions?: AcpSessionConfigOption[]; + }, ) { super(); this.#sessionId = initParams?.sessionId ?? uuid(); + this.#createdAt = initParams?.createdAt ?? Date.now(); this.history = initParams?.history ?? new MsgHistoryManager(this.chatFeatureRegistry); this.#modelId = initParams?.modelId; this.#title = initParams?.title ?? ''; + this.#agentModes = initParams?.agentModes ?? []; + this.#currentModeId = initParams?.currentModeId; + this.#agentModels = initParams?.agentModels ?? []; + this.#configOptions = initParams?.configOptions ?? []; } #title: string; @@ -314,11 +334,20 @@ export class ChatModel extends Disposable implements IChatModel { return this.#title; } + setTitle(title: string): void { + this.#title = title; + } + #sessionId: string; get sessionId(): string { return this.#sessionId; } + #createdAt: number; + get createdAt(): number { + return this.#createdAt; + } + #requests: Map = new Map(); get requests(): ChatRequestModel[] { return Array.from(this.#requests.values()); @@ -347,6 +376,63 @@ export class ChatModel extends Disposable implements IChatModel { this.#modelId = modelId; } + #agentModes: AcpSessionModeOption[] = []; + + public get agentModes(): AcpSessionModeOption[] { + return this.#agentModes; + } + + set agentModes(agentModes: AcpSessionModeOption[] | undefined) { + this.#agentModes = agentModes ?? []; + } + + #currentModeId?: string; + + public get currentModeId(): string | undefined { + return this.#currentModeId; + } + + set currentModeId(currentModeId: string | undefined) { + this.#currentModeId = currentModeId; + } + + #agentModels: AcpSessionModelOption[] = []; + + public get agentModels(): AcpSessionModelOption[] { + return this.#agentModels; + } + + set agentModels(agentModels: AcpSessionModelOption[] | undefined) { + this.#agentModels = agentModels ?? []; + } + + #configOptions: AcpSessionConfigOption[] = []; + + public get configOptions(): AcpSessionConfigOption[] { + return this.#configOptions; + } + + set configOptions(configOptions: AcpSessionConfigOption[] | undefined) { + this.#configOptions = configOptions ?? []; + } + + #threadStatus: ThreadStatus = 'idle'; + + get threadStatus(): ThreadStatus { + return this.#threadStatus; + } + + setThreadStatus(status: ThreadStatus): void { + if (this.#threadStatus === status) { + return; + } + 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,13 +606,19 @@ export class ChatModel extends Disposable implements IChatModel { override dispose(): void { super.dispose(); + this._onThreadStatusChange.dispose(); this.#requests.forEach((r) => r.response.dispose()); } toJSON() { return { sessionId: this.sessionId, + createdAt: this.createdAt, modelId: this.modelId, + agentModes: this.agentModes, + currentModeId: this.currentModeId, + agentModels: this.agentModels, + configOptions: this.configOptions, history: this.history, requests: this.requests, }; diff --git a/packages/ai-native/src/browser/chat/chat.api.service.ts b/packages/ai-native/src/browser/chat/chat.api.service.ts index a13091fde4..23475f87b4 100644 --- a/packages/ai-native/src/browser/chat/chat.api.service.ts +++ b/packages/ai-native/src/browser/chat/chat.api.service.ts @@ -1,9 +1,9 @@ import { Autowired, Injectable } from '@opensumi/di'; import { Disposable, Emitter, Event } from '@opensumi/ide-core-common'; import { IChatComponent, IChatContent } from '@opensumi/ide-core-common/lib/types/ai-native'; -import { IMainLayoutService } from '@opensumi/ide-main-layout'; -import { AI_CHAT_VIEW_ID, IChatInternalService, IChatMessageListItem, IChatMessageStructure } from '../../common'; +import { IChatInternalService, IChatMessageListItem, IChatMessageStructure } from '../../common'; +import { AIPanelLayoutService } from '../layout/panel-layout.service'; import { ChatInternalService } from './chat.internal.service'; @@ -12,8 +12,8 @@ export class ChatService extends Disposable { @Autowired(IChatInternalService) chatInternalService: ChatInternalService; - @Autowired(IMainLayoutService) - private mainLayoutService: IMainLayoutService; + @Autowired(AIPanelLayoutService) + private panelLayoutService: AIPanelLayoutService; private readonly _onChatMessageLaunch = new Emitter(); public readonly onChatMessageLaunch: Event = this._onChatMessageLaunch.event; @@ -35,7 +35,7 @@ export class ChatService extends Disposable { * 显示聊天视图 */ public showChatView() { - this.mainLayoutService.toggleSlot(AI_CHAT_VIEW_ID, true); + this.panelLayoutService.showAIChatView(); } public sendMessage(data: IChatMessageStructure) { diff --git a/packages/ai-native/src/browser/chat/chat.input.registry.ts b/packages/ai-native/src/browser/chat/chat.input.registry.ts index 7b4897dd86..4aaf1878f2 100644 --- a/packages/ai-native/src/browser/chat/chat.input.registry.ts +++ b/packages/ai-native/src/browser/chat/chat.input.registry.ts @@ -6,6 +6,8 @@ import { Disposable, IDisposable } from '@opensumi/ide-core-common'; import { LLMContextService } from '../../common/llm-context'; +import type { AcpSessionConfigOption, AcpSessionModelOption } from './session-provider'; + /** * Props interface for chat input components. * Based on AcpChatMentionInput's prop surface — all registered inputs must satisfy this contract. @@ -39,6 +41,10 @@ export interface IChatInputProps { sessionModelId?: string; contextService?: LLMContextService; agentModes?: Array<{ id: string; name: string; description?: string }>; + currentModeId?: string; + agentModels?: AcpSessionModelOption[]; + currentModelId?: string; + configOptions?: AcpSessionConfigOption[]; agentCwd?: string; } 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..69b441dbdc 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 @@ -1,11 +1,105 @@ import { Autowired, Injectable } from '@opensumi/di'; -import { AINativeConfigService } from '@opensumi/ide-core-browser'; -import { AvailableCommand, Emitter, Event } from '@opensumi/ide-core-common'; +import { AINativeConfigService, ILogger } from '@opensumi/ide-core-browser'; +import { AvailableCommand, Emitter, Event, IDisposable } 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 { ChatModel, ChatRequestModel } from './chat-model'; import { ChatInternalService } from './chat.internal.service'; +import { AcpSessionConfigOption, AcpSessionModeOption, AcpSessionModelOption } from './session-provider'; + +const ACP_LOAD_SESSION_FALLBACK_MESSAGE = + 'Unable to open this chat history. A new chat draft is ready, and a session will be created when you send a message.'; +const ACP_LOAD_SESSION_NOT_FOUND_MESSAGE = + 'This chat history is no longer available. A new chat draft is ready, and a session will be created when you send a message.'; + +function getReadableErrorMessage(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 = errorRecord.message; + if (typeof message === 'string' && message.trim()) { + return message; + } + + const nestedError = errorRecord.error; + if (nestedError && typeof nestedError === 'object') { + const nestedMessage = (nestedError as Record).message; + if (typeof nestedMessage === 'string' && nestedMessage.trim()) { + return nestedMessage; + } + } + } + + return ''; +} + +export function formatAcpLoadSessionFallbackMessage(error: unknown): string { + const errorMessage = getReadableErrorMessage(error); + if (/session .*not found|not found|does not exist|no session/i.test(errorMessage)) { + return ACP_LOAD_SESSION_NOT_FOUND_MESSAGE; + } + + return ACP_LOAD_SESSION_FALLBACK_MESSAGE; +} + +function updateConfigOptionValue(option: Record, value: boolean | string): Record { + const next = { ...option }; + if (next.kind && typeof next.kind === 'object') { + next.kind = { ...next.kind }; + if ('currentValue' in next.kind) { + next.kind.currentValue = value; + } + } + if ('currentValue' in next) { + next.currentValue = value; + } + if ('value' in next) { + next.value = value; + } + if ('current_value' in next) { + next.current_value = value; + } + return next; +} + +function readConfigOptionId(option: AcpSessionConfigOption): string | undefined { + const rawId = option.id || option.configId; + if (typeof rawId === 'string') { + return rawId; + } + if (rawId && typeof rawId === 'object' && typeof (rawId as { id?: unknown }).id === 'string') { + return (rawId as { id: string }).id; + } + return undefined; +} + +function readConfigOptionValue(option: AcpSessionConfigOption): boolean | string | undefined { + const kind = option.kind && typeof option.kind === 'object' ? option.kind : undefined; + const value = kind?.currentValue ?? option.currentValue ?? option.current_value ?? option.value; + return typeof value === 'boolean' || typeof value === 'string' ? value : undefined; +} + +function cloneConfigOptions(configOptions?: AcpSessionConfigOption[]): AcpSessionConfigOption[] | undefined { + return configOptions?.map((option) => ({ ...option })); +} + +interface AcpDraftSessionState { + agentModes?: AcpSessionModeOption[]; + currentModeId?: string; + agentModels?: AcpSessionModelOption[]; + modelId?: string; + configOptions?: AcpSessionConfigOption[]; +} @Injectable() export class AcpChatInternalService extends ChatInternalService { @@ -15,6 +109,12 @@ export class AcpChatInternalService extends ChatInternalService { @Autowired(IMessageService) private messageService: IMessageService; + @Autowired(AcpPermissionBridgeService) + private permissionBridgeService: AcpPermissionBridgeService; + + @Autowired(ILogger) + protected readonly logger: ILogger; + private readonly _onModeChange = new Emitter(); public readonly onModeChange: Event = this._onModeChange.event; @@ -29,6 +129,42 @@ export class AcpChatInternalService extends ChatInternalService { private availableCommands: AvailableCommand[] = []; + private draftSessionState: AcpDraftSessionState = {}; + + private sessionStateDisposable: IDisposable | undefined; + + private storageInitDisposable: IDisposable | undefined; + + private sessionCreationPromise: Promise | undefined; + + private bootstrapSessionId: string | undefined; + + private bootstrapSessionAttempted = false; + + private stripAcpPrefix(sessionId: string): string { + return sessionId.startsWith('acp:') ? sessionId.slice(4) : sessionId; + } + + private hasDraftSessionState(): boolean { + return Boolean( + this.draftSessionState.currentModeId || + this.draftSessionState.modelId || + this.draftSessionState.agentModes?.length || + this.draftSessionState.agentModels?.length || + this.draftSessionState.configOptions?.length, + ); + } + + private isUnusedBootstrapSession(model: ChatModel | undefined): boolean { + return Boolean( + model && + this.bootstrapSessionId && + model.sessionId === this.bootstrapSessionId && + model.history.getMessages().length === 0 && + model.requests.length === 0, + ); + } + getAvailableCommands(): AvailableCommand[] { return this.availableCommands; } @@ -38,12 +174,77 @@ export class AcpChatInternalService extends ChatInternalService { this._onAvailableCommandsChange.fire(commands); } + getDraftSessionState(): AcpDraftSessionState { + return this.draftSessionState; + } + + getVisibleSessions(): ChatModel[] { + return this.chatManagerService.getSessions().filter((session) => !this.isUnusedBootstrapSession(session)); + } + public get onStorageInit() { return this.chatManagerService.onStorageInit; } + override createRequest( + input: string, + agentId: string, + images?: string[], + command?: string, + ): ChatRequestModel | undefined { + const sessionId = this._sessionModel?.sessionId; + this.logger.log( + `[ACP Chat][Frontend] createRequest start — sessionId=${sessionId ?? '(empty)'}, agentId=${ + agentId || '(empty)' + }, command=${command || '(empty)'}, messageChars=${input.length}, images=${images?.length ?? 0}`, + ); + + const request = super.createRequest(input, agentId, images, command); + this.logger.log( + `[ACP Chat][Frontend] createRequest ${request ? 'done' : 'skipped'} — sessionId=${ + sessionId ?? '(empty)' + }, requestId=${request?.requestId ?? '(empty)'}`, + ); + return request; + } + + override sendRequest(request: ChatRequestModel, regenerate = false) { + const sessionId = this._sessionModel?.sessionId; + this.logger.log( + `[ACP Chat][Frontend] sendRequest start — sessionId=${sessionId ?? '(empty)'}, requestId=${ + request.requestId + }, regenerate=${regenerate}, agentId=${request.message.agentId}, command=${ + request.message.command || '(empty)' + }, messageChars=${request.message.prompt.length}, images=${request.message.images?.length ?? 0}`, + ); + + const result = super.sendRequest(request, regenerate); + Promise.resolve(result).then( + () => { + this.logger.log( + `[ACP Chat][Frontend] sendRequest done — sessionId=${sessionId ?? '(empty)'}, requestId=${request.requestId}`, + ); + }, + (error) => { + const message = error instanceof Error ? error.message : String(error); + this.logger.error( + `[ACP Chat][Frontend] sendRequest error — sessionId=${sessionId ?? '(empty)'}, requestId=${ + request.requestId + }, error=${message}`, + ); + }, + ); + return result; + } + override init() { - this.chatManagerService.onStorageInit(async () => { + if (this.storageInitDisposable) { + return; + } + + this.ensureSessionStateListener(); + + this.storageInitDisposable = this.chatManagerService.onStorageInit(async () => { if (this.aiNativeConfigService.capabilities.supportsAgentMode) { return; } @@ -54,46 +255,287 @@ export class AcpChatInternalService extends ChatInternalService { await this.createSessionModel(); } }); + this.addDispose(this.storageInitDisposable); + } + + private ensureSessionStateListener(): void { + if (this.sessionStateDisposable) { + return; + } + + const acpManager = this.chatManagerService as AcpChatManagerService; + if (!acpManager.onDidApplySessionState) { + return; + } + + this.sessionStateDisposable = acpManager.onDidApplySessionState((event) => { + if (!this._sessionModel || event.sessionId !== this._sessionModel.sessionId) { + return; + } + + this._onSessionModelChange.fire(this._sessionModel); + if (event.currentModeId !== undefined && event.currentModeId !== event.previousModeId) { + this._onModeChange.fire(event.currentModeId); + } + }); + this.addDispose(this.sessionStateDisposable); + } + + private async doStartSessionModel(): Promise { + const draftSessionState = this.draftSessionState; + this._sessionModel = await this.chatManagerService.startSession(); + await this.applyDraftSessionState(this._sessionModel, draftSessionState); + const acpManager = this.chatManagerService as AcpChatManagerService; + this.setAvailableCommands(acpManager.getAvailableCommands()); + this.draftSessionState = this.createDraftStateFromModel(this._sessionModel) || {}; + 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); + return this._sessionModel; + } + + private async startSessionModel(): Promise { + if (this.sessionCreationPromise) { + return this.sessionCreationPromise; + } + + this._onSessionLoadingChange.fire(true); + this.sessionCreationPromise = this.doStartSessionModel(); + try { + return await this.sessionCreationPromise; + } finally { + this.sessionCreationPromise = undefined; + this._onSessionLoadingChange.fire(false); + } + } + + async ensureSessionModel(): Promise { + if (this._sessionModel) { + return this._sessionModel; + } + + return this.startSessionModel(); + } + + async ensureBootstrapSessionModel(): Promise { + if (!this.aiNativeConfigService.capabilities.supportsAgentMode || this._sessionModel) { + return this._sessionModel; + } + + if (this.bootstrapSessionAttempted || this.hasDraftSessionState()) { + return undefined; + } + + this.bootstrapSessionAttempted = true; + try { + const model = await this.startSessionModel(); + this.bootstrapSessionId = model.sessionId; + return model; + } catch (error) { + this.logger.warn?.('[ACP Chat][Frontend] Failed to create bootstrap session', error); + return undefined; + } + } + + enterDraftSession(options?: { force?: boolean }): void { + if (!options?.force && this.isUnusedBootstrapSession(this._sessionModel)) { + return; + } + + this.draftSessionState = this.createDraftStateFromModel(this._sessionModel) || this.draftSessionState; + this._sessionModel = undefined as unknown as ChatModel; + this.permissionBridgeService.setActiveSession(undefined); + this._onSessionModelChange.fire(undefined); + this._onModeChange.fire(''); + this._onChangeSession.fire(''); + } + + private createDraftStateFromModel(model: ChatModel | undefined): AcpDraftSessionState | undefined { + if (!model) { + return undefined; + } + + return { + agentModes: model.agentModes ? [...model.agentModes] : undefined, + currentModeId: model.currentModeId, + agentModels: model.agentModels ? [...model.agentModels] : undefined, + modelId: model.modelId, + configOptions: cloneConfigOptions(model.configOptions), + }; + } + + private fireDraftSessionStateChange(): void { + this._onSessionModelChange.fire(undefined); + } + + private updateDraftConfigOption(configId: string, value: boolean | string): void { + this.draftSessionState = { + ...this.draftSessionState, + configOptions: (this.draftSessionState.configOptions || []).map((option) => + readConfigOptionId(option) === configId ? updateConfigOptionValue(option, value) : option, + ), + }; + this.fireDraftSessionStateChange(); + } + + private async applyDraftSessionState(model: ChatModel, draftState: AcpDraftSessionState): Promise { + const sessionId = this.stripAcpPrefix(model.sessionId); + + if ( + draftState.currentModeId && + draftState.currentModeId !== model.currentModeId && + model.agentModes?.some((mode) => mode.id === draftState.currentModeId) + ) { + try { + await this.aiBackService.setSessionMode?.(sessionId, draftState.currentModeId); + model.currentModeId = draftState.currentModeId; + this._onModeChange.fire(draftState.currentModeId); + } catch (error) { + this.logger.warn?.(`[ACP Chat][Frontend] Failed to apply draft mode "${draftState.currentModeId}"`, error); + } + } + + if ( + draftState.modelId && + draftState.modelId !== model.modelId && + model.agentModels?.some((agentModel) => agentModel.modelId === draftState.modelId) + ) { + try { + await this.aiBackService.setSessionModel?.(sessionId, draftState.modelId); + model.modelId = draftState.modelId; + } catch (error) { + this.logger.warn?.(`[ACP Chat][Frontend] Failed to apply draft model "${draftState.modelId}"`, error); + } + } + + const draftConfigValues = new Map(); + (draftState.configOptions || []).forEach((option) => { + const id = readConfigOptionId(option); + const value = readConfigOptionValue(option); + if (id && value !== undefined) { + draftConfigValues.set(id, value); + } + }); + + if (draftConfigValues.size === 0) { + return; + } + + const nextConfigOptions: AcpSessionConfigOption[] = []; + for (const option of model.configOptions || []) { + const optionId = readConfigOptionId(option); + const draftValue = optionId ? draftConfigValues.get(optionId) : undefined; + if (!optionId || draftValue === undefined || readConfigOptionValue(option) === draftValue) { + nextConfigOptions.push(option); + continue; + } + + try { + await this.aiBackService.setSessionConfigOption?.(sessionId, optionId, draftValue); + nextConfigOptions.push(updateConfigOptionValue(option, draftValue)); + } catch (error) { + this.logger.warn?.(`[ACP Chat][Frontend] Failed to apply draft config option "${optionId}"`, error); + nextConfigOptions.push(option); + } + } + + model.configOptions = nextConfigOptions; } async setSessionMode(modeId: string): Promise { - const sessionId = this._sessionModel?.sessionId; + const sessionId = this._sessionModel ? this.stripAcpPrefix(this._sessionModel.sessionId) : undefined; if (!sessionId) { - throw new Error('No active session'); + this.draftSessionState = { + ...this.draftSessionState, + currentModeId: modeId, + }; + this._onModeChange.fire(modeId); + this.fireDraftSessionStateChange(); + return; } try { await this.aiBackService.setSessionMode?.(sessionId, modeId); + if (this._sessionModel) { + this._sessionModel.currentModeId = modeId; + this._onSessionModelChange.fire(this._sessionModel); + } this._onModeChange.fire(modeId); } catch (e) { this.messageService.error((e as Error).message); } } + async setSessionModel(modelId: string): Promise { + const sessionId = this._sessionModel ? this.stripAcpPrefix(this._sessionModel.sessionId) : undefined; + if (!sessionId) { + this.draftSessionState = { + ...this.draftSessionState, + modelId, + }; + this.fireDraftSessionStateChange(); + return; + } + + try { + await this.aiBackService.setSessionModel?.(sessionId, modelId); + if (this._sessionModel) { + this._sessionModel.modelId = modelId; + this._onSessionModelChange.fire(this._sessionModel); + } + } catch (e) { + this.messageService.error((e as Error).message); + } + } + + async setSessionConfigOption(configId: string, value: boolean | string): Promise { + const sessionId = this._sessionModel ? this.stripAcpPrefix(this._sessionModel.sessionId) : undefined; + if (!sessionId) { + this.updateDraftConfigOption(configId, value); + return; + } + + try { + await this.aiBackService.setSessionConfigOption?.(sessionId, configId, value); + if (this._sessionModel) { + this._sessionModel.configOptions = this._sessionModel.configOptions.map((option) => { + const optionId = option.id || option.configId; + return optionId === configId ? updateConfigOptionValue(option, value) : option; + }); + this._onSessionModelChange.fire(this._sessionModel); + } + } catch (e) { + this.messageService.error((e as Error).message); + } + } + override async createSessionModel() { - this._onSessionLoadingChange.fire(true); - this._sessionModel = await this.chatManagerService.startSession(); - const acpManager = this.chatManagerService as AcpChatManagerService; - this.setAvailableCommands(acpManager.getAvailableCommands()); - this._onSessionModelChange.fire(this._sessionModel); - this._onChangeSession.fire(this._sessionModel.sessionId); - this._onSessionLoadingChange.fire(false); + try { + await this.startSessionModel(); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + this.messageService.error(`Failed to create session. (${errorMessage})`); + } } override async clearSessionModel(sessionId?: string) { sessionId = sessionId || this._sessionModel?.sessionId; if (!sessionId) { - throw new Error('No active session'); + this.enterDraftSession({ force: true }); + return; } this._onWillClearSession.fire(sessionId); + const clearedSessionId = + this._sessionModel && sessionId === this._sessionModel.sessionId ? this.stripAcpPrefix(sessionId) : undefined; this.chatManagerService.clearSession(sessionId); - 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); + if (clearedSessionId) { + this.permissionBridgeService.clearSessionDialogs(clearedSessionId); } - if (this._sessionModel) { + if (this._sessionModel && sessionId === this._sessionModel.sessionId) { + this.enterDraftSession({ force: true }); + } else if (this._sessionModel) { this._onChangeSession.fire(this._sessionModel.sessionId); } } @@ -102,6 +544,12 @@ export class AcpChatInternalService extends ChatInternalService { return this.chatManagerService.getSessions(); } + async loadSessionModel(sessionId: string) { + const acpManager = this.chatManagerService as AcpChatManagerService; + await acpManager.loadSession(sessionId); + return this.chatManagerService.getSession(sessionId); + } + async getSessionsByAcp() { const acpManager = this.chatManagerService as AcpChatManagerService; await acpManager.loadSessionList(); @@ -119,24 +567,29 @@ export class AcpChatInternalService extends ChatInternalService { await acpManager.loadSession(sessionId); const updatedSession = this.chatManagerService.getSession(sessionId); if (!updatedSession) { - this.messageService.info(`Session ${sessionId} not found, creating a new session.`); - await this.createSessionModel(); + this.messageService.info( + `Session ${sessionId} not found. A new chat draft is ready, and a session will be created when you send a message.`, + ); + this.enterDraftSession({ force: true }); 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); } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - this.messageService.info(`Failed to load session, creating a new session. (${errorMessage})`); - await this.createSessionModel(); + this.messageService.info(formatAcpLoadSessionFallbackMessage(error)); + this.enterDraftSession({ force: true }); } finally { this._onSessionLoadingChange.fire(false); } } 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.module.less b/packages/ai-native/src/browser/chat/chat.module.less index 9aace560e3..b063aa75cb 100644 --- a/packages/ai-native/src/browser/chat/chat.module.less +++ b/packages/ai-native/src/browser/chat/chat.module.less @@ -277,6 +277,53 @@ } } } + + &:has(.chat_history_agentic) { + flex-direction: row; + + .header_container { + flex: 0 0 224px; + width: 224px; + height: 100%; + max-height: none; + min-width: 0; + padding: 0; + border-right: 1px solid var(--panel-border); + background-color: var(--panel-background); + + .header_agentic { + position: relative; + display: block; + height: 100%; + min-height: 0; + + .action_btn { + position: absolute; + top: 8px; + right: 8px; + z-index: 1; + } + } + } + + &:has(.chat_history_agentic_collapsed) { + .header_container { + flex-basis: 72px; + width: 72px; + } + } + + .body_container { + flex: 1 1 auto; + height: 100%; + min-height: 0; + min-width: 0; + + .left_bar { + min-width: 0; + } + } + } } .chat_tips_container { @@ -293,6 +340,17 @@ color: var(--design-text-foreground); } +.chat_history_agentic { + width: 100%; + height: 100%; + min-height: 0; + color: var(--design-text-foreground); +} + +.chat_history_agentic_collapsed { + min-width: 0; +} + .loading_container { display: flex; flex-direction: column; @@ -304,27 +362,6 @@ font-size: 12px; } -.timeout_hint { - color: var(--design-text-secondary); - font-size: 12px; - margin-top: 4px; -} - -.retry_button { - margin-top: 4px; - padding: 4px 16px; - font-size: 12px; - color: var(--button-foreground); - background-color: var(--button-background); - border: none; - border-radius: 4px; - cursor: pointer; - - &:hover { - background-color: var(--button-hoverBackground); - } -} - .acp_error_container { display: flex; flex-direction: column; 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..c586c978ed 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,12 @@ 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 { hasAcpChatSendPayload } from '../components/acp/chat-input-validation'; 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 +65,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'; @@ -75,6 +76,8 @@ import { AcpChatInternalService } from './chat.internal.service.acp'; import styles from './chat.module.less'; import { ChatRenderRegistry } from './chat.render.registry'; +import type { MsgHistoryManager } from '../model/msg-history-manager'; + const SCROLL_CLASSNAME = 'chat_scroll'; interface TDispatchAction { @@ -84,6 +87,21 @@ interface TDispatchAction { const MAX_TITLE_LENGTH = 100; +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function getSessionCreatedAt(session: ChatModel): number { + const firstMessage = session.history.getMessages()[0]; + return session.createdAt || firstMessage?.timestamp || firstMessage?.replyStartTime || 0; +} + +function getVisibleAcpSessions(aiChatService: AcpChatInternalService): ChatModel[] { + return typeof aiChatService.getVisibleSessions === 'function' + ? aiChatService.getVisibleSessions() + : aiChatService.getSessions(); +} + const getFileChanges = (codeBlocks: CodeBlockData[]) => codeBlocks .map((block) => { @@ -140,10 +158,8 @@ export const AIChatViewACPContent = () => { const llmContextService = useInjectable(LLMContextServiceToken); const layoutService = useInjectable(IMainLayoutService); + const messageService = useInjectable(IMessageService); const msgHistoryManager = aiChatService.sessionModel?.history; - if (!msgHistoryManager) { - return null; - } const containerRef = React.useRef(null); const autoScroll = React.useRef(true); const chatInputRef = React.useRef<{ setInputValue: (v: string) => void } | null>(null); @@ -234,6 +250,14 @@ export const AIChatViewACPContent = () => { }, [chatFeatureRegistry, chatAgentService]); useUpdateOnEvent(aiChatService.onChangeSession); + useUpdateOnEvent(aiChatService.onSessionModelChange); + + const draftSessionState = aiChatService.getDraftSessionState(); + const footerAgentModes = aiChatService.sessionModel?.agentModes || draftSessionState.agentModes; + const footerCurrentModeId = aiChatService.sessionModel?.currentModeId || draftSessionState.currentModeId; + const footerAgentModels = aiChatService.sessionModel?.agentModels || draftSessionState.agentModels; + const footerCurrentModelId = aiChatService.sessionModel?.modelId || draftSessionState.modelId; + const footerConfigOptions = aiChatService.sessionModel?.configOptions || draftSessionState.configOptions; const ChatInputWrapperRender = React.useMemo(() => { // 1. 优先使用 ChatInputRegistry 注册的输入组件(按优先级 + when 条件匹配) @@ -339,6 +363,10 @@ export const AIChatViewACPContent = () => { disposer.addDispose( chatApiService.onChatReplyMessageLaunch((data) => { + if (!msgHistoryManager) { + return; + } + if (data.kind === 'content') { const relationId = aiReporter.start(AIServiceType.CustomReply, { message: data.content, @@ -472,12 +500,13 @@ export const AIChatViewACPContent = () => { relationId: string; requestId: string; startTime: number; + history: MsgHistoryManager; command?: string; agentId?: string; }) => { - const { userMessage, relationId, requestId, render, startTime, command, agentId } = value; + const { userMessage, relationId, requestId, render, startTime, history, command, agentId } = value; - msgHistoryManager.addAssistantMessage({ + history.addAssistantMessage({ type: 'component', content: '', }); @@ -501,7 +530,7 @@ export const AIChatViewACPContent = () => { handleDispatchMessage({ type: 'add', payload: [aiMessage] }); }, - [containerRef, msgHistoryManager], + [containerRef], ); const renderUserMessage = React.useCallback( @@ -554,8 +583,9 @@ export const AIChatViewACPContent = () => { command?: string; startTime: number; msgId: string; + history: MsgHistoryManager; }) => { - const { message, agentId, request, relationId, command, startTime, msgId } = renderModel; + const { message, agentId, request, relationId, command, startTime, msgId, history } = renderModel; const visibleAgentId = agentId === ChatProxyService.AGENT_ID ? '' : agentId; @@ -569,6 +599,7 @@ export const AIChatViewACPContent = () => { relationId, requestId: request.requestId, startTime, + history, agentId, command, }); @@ -589,7 +620,7 @@ export const AIChatViewACPContent = () => { onDidChange={() => { scrollToBottom(); }} - history={msgHistoryManager} + history={history} onDone={() => { setLoading(false); }} @@ -599,12 +630,13 @@ export const AIChatViewACPContent = () => { } }} msgId={msgId} + collapseReasoningByDefault /> ), }); handleDispatchMessage({ type: 'add', payload: [aiMessage] }); }, - [chatRenderRegistry, msgHistoryManager, scrollToBottom], + [chatRenderRegistry, scrollToBottom], ); const renderSimpleMarkdownReply = React.useCallback( @@ -651,6 +683,19 @@ export const AIChatViewACPContent = () => { const { message, images, agentId, command, reportExtra } = value; const { actionType, actionSource } = reportExtra || {}; + if (!hasAcpChatSendPayload({ message, images, command })) { + return false; + } + + let sessionModel: ChatModel; + try { + sessionModel = await aiChatService.ensureSessionModel(); + } catch (error) { + messageService.error(`Failed to create session. (${getErrorMessage(error)})`); + return false; + } + + const activeHistory = sessionModel.history; const request = aiChatService.createRequest( message.replaceAll(LLM_CONTEXT_KEY_REGEX, ''), agentId!, @@ -658,7 +703,7 @@ export const AIChatViewACPContent = () => { command, ); if (!request) { - return; + return false; } setLoading(true); @@ -674,12 +719,12 @@ export const AIChatViewACPContent = () => { userMessage: message, actionType, actionSource, - sessionId: aiChatService.sessionModel?.sessionId, + sessionId: sessionModel.sessionId, }, // 由于涉及 tool 调用,超时时间设置长一点 600 * 1000, ); - msgHistoryManager.addUserMessage({ + activeHistory.addUserMessage({ content: message, images: images || [], agentId: agentId!, @@ -697,7 +742,7 @@ export const AIChatViewACPContent = () => { aiChatService.sendRequest(request); - const msgId = msgHistoryManager.addAssistantMessage({ + const msgId = activeHistory.addAssistantMessage({ content: '', relationId, requestId: request.requestId, @@ -707,7 +752,7 @@ export const AIChatViewACPContent = () => { // 创建消息时,设置当前活跃的消息信息,便于toolCall打点 mcpServerRegistry.activeMessageInfo = { messageId: msgId, - sessionId: aiChatService.sessionModel?.sessionId, + sessionId: sessionModel.sessionId, }; await renderReply({ @@ -718,13 +763,30 @@ export const AIChatViewACPContent = () => { command, request, msgId, + history: activeHistory, }); + return true; }, - [chatRenderRegistry, chatRenderRegistry.chatUserRoleRender, msgHistoryManager, scrollToBottom, loading], + [ + aiChatService, + aiReporter, + chatRenderRegistry, + chatRenderRegistry.chatUserRoleRender, + loading, + mcpServerRegistry, + messageService, + renderReply, + renderUserMessage, + scrollToBottom, + ], ); const handleSend = React.useCallback( async (message: string, images?: string[], agentId?: string, command?: string) => { + if (!hasAcpChatSendPayload({ message, images, command })) { + return false; + } + const reportExtra = { actionSource: ActionSourceEnum.Chat, actionType: ActionTypeEnum.Send, @@ -784,8 +846,10 @@ export const AIChatViewACPContent = () => { ); } } - return handleAgentReply({ message: processedContent, images, agentId, command, reportExtra }).finally(() => { - setHasUserSentMessage(true); + return handleAgentReply({ message: processedContent, images, agentId, command, reportExtra }).then((sent) => { + if (sent) { + setHasUserSentMessage(true); + } }); }, [handleAgentReply, setHasUserSentMessage], @@ -820,6 +884,10 @@ export const AIChatViewACPContent = () => { const recover = React.useCallback( async (cancellationToken: CancellationToken) => { + if (!msgHistoryManager) { + return; + } + for (const msg of msgHistoryManager.getMessages()) { if (cancellationToken.isCancellationRequested) { return; @@ -846,6 +914,7 @@ export const AIChatViewACPContent = () => { command: msg.agentCommand, startTime: msg.replyStartTime!, request, + history: msgHistoryManager, }); } else if (msg.role === ChatMessageRole.Assistant && msg.content) { await renderSimpleMarkdownReply({ @@ -864,7 +933,7 @@ export const AIChatViewACPContent = () => { } } }, - [renderReply], + [msgHistoryManager, renderCustomComponent, renderReply, renderSimpleMarkdownReply, renderUserMessage], ); React.useEffect(() => { @@ -891,7 +960,7 @@ export const AIChatViewACPContent = () => {
- {!hasUserSentMessage && chatRenderRegistry.chatWelcomePageRender ? ( + {!hasUserSentMessage && messageListData.length <= 1 && chatRenderRegistry.chatWelcomePageRender ? ( React.createElement(chatRenderRegistry.chatWelcomePageRender, { onSend: handleSend, agentId, @@ -964,10 +1033,17 @@ export const AIChatViewACPContent = () => { setCommand={setCommand} contextService={llmContextService} ref={chatInputRef} - disableModelSelector={sessionModelId !== undefined || loading} + disableModelSelector={ + aiNativeConfigService.capabilities.supportsAgentMode ? loading : sessionModelId !== undefined || loading + } sessionModelId={sessionModelId} + agentModes={footerAgentModes} + currentModeId={footerCurrentModeId} + agentModels={footerAgentModels} + currentModelId={footerCurrentModelId} + configOptions={footerConfigOptions} agentCwd={appConfig.workspaceDir} - placeholder='message claude-agent-acp @to include context, / for command' + placeholder={localize('aiNative.chat.input.placeholder.acp')} />
@@ -977,28 +1053,20 @@ export const AIChatViewACPContent = () => { }; export function DefaultChatViewHeaderACP({ - handleClear, handleCloseChatView, }: { handleClear: () => any; handleCloseChatView: () => any; }) { 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 { - aiChatService.createSessionModel(); - } catch (error) { - messageService.error(error.message); - } - } + aiChatService.enterDraftSession(); }, [aiChatService]); const handleHistoryItemSelect = React.useCallback( (item: IChatHistoryItem) => { @@ -1038,8 +1106,26 @@ 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 currentMessages = aiChatService.sessionModel?.history.getMessages() || []; const latestUserMessage = [...currentMessages].find((m) => m.role === ChatMessageRole.User); const currentTitle = latestUserMessage ? cleanAttachedTextWrapper(latestUserMessage.content).slice(0, MAX_TITLE_LENGTH) @@ -1067,27 +1153,49 @@ export function DefaultChatViewHeaderACP({ } } + const sessions = getVisibleAcpSessions(aiChatService); + 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 = + const messageTitle = 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; + const title = session.title || messageTitle; + const createdAt = getSessionCreatedAt(session); return { id: session.sessionId, title, - updatedAt, - // TODO: 后续支持 + createdAt, 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(); @@ -1095,18 +1203,24 @@ export function DefaultChatViewHeaderACP({ return; } sessionListenIds.add(sessionId); - toDispose.push( - aiChatService.sessionModel?.history.onMessageChange(() => { - getHistoryList(); - }), - ); - }), - ); - toDispose.push( - aiChatService.sessionModel?.history.onMessageChange(() => { - getHistoryList(); + const history = aiChatService.sessionModel?.history; + if (history) { + toDispose.push( + history.onMessageChange(() => { + getHistoryList(); + }), + ); + } }), ); + const activeHistory = aiChatService.sessionModel?.history; + if (activeHistory) { + toDispose.push( + activeHistory.onMessageChange(() => { + getHistoryList(); + }), + ); + } return () => { toDispose.dispose(); }; @@ -1114,52 +1228,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(); + const webMcpEnabled = await this.mcpConfigService.isBuiltinMCPEnabled(); + + 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', {}), + threadPoolSize: this.preferenceService.get( + AINativeSettingSectionsId.AcpThreadPoolSize, + DEFAULT_ACP_THREAD_POOL_SIZE, + ), + webMcpEnabled, + }, + mcpServers, + }); } } diff --git a/packages/ai-native/src/browser/chat/session-provider.ts b/packages/ai-native/src/browser/chat/session-provider.ts index 45773e7e69..1d65a276c0 100644 --- a/packages/ai-native/src/browser/chat/session-provider.ts +++ b/packages/ai-native/src/browser/chat/session-provider.ts @@ -9,7 +9,12 @@ import { IChatProgressResponseContent } from './chat-model'; */ export interface ISessionModel { sessionId: string; + createdAt?: number; modelId?: string; + agentModes?: AcpSessionModeOption[]; + currentModeId?: string; + agentModels?: AcpSessionModelOption[]; + configOptions?: AcpSessionConfigOption[]; history: { additional: Record; messages: IHistoryChatMessage[] }; requests: { requestId: string; @@ -34,6 +39,20 @@ export interface ISessionModelExtension { availableCommands: AvailableCommand[]; } +export interface AcpSessionModeOption { + id: string; + name: string; + description?: string; +} + +export interface AcpSessionModelOption { + modelId: string; + name: string; + description?: string | null; +} + +export type AcpSessionConfigOption = Record; + /** * Session Provider 接口 * 抽象不同数据源的 Session 加载逻辑 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} +
void; onDone?: () => void; msgId: string; + keepReasoningExpandedOnComplete?: boolean; + collapseReasoningByDefault?: boolean; } +const expandedThinkingIndexSetMap = new Map>(); + +const getReasoningIndexSet = (responseContents: IChatProgressResponseContent[], excludeIndexSet?: Set) => + new Set( + responseContents + .map((item, index) => (item.kind === 'reasoning' && !excludeIndexSet?.has(index) ? index : -1)) + .filter((item) => item !== -1), + ); + const TreeRenderer = (props: { treeData: IChatResponseProgressFileTreeData }) => { const labelService = useInjectable(LabelService); const commandService = useInjectable(CommandService); @@ -216,6 +227,8 @@ export const ChatReply = (props: IChatReplyProps) => { command, history, msgId, + keepReasoningExpandedOnComplete = false, + collapseReasoningByDefault = false, } = props; const [, update] = useReducer((num) => (num + 1) % 1_000_000, 0); @@ -226,27 +239,24 @@ export const ChatReply = (props: IChatReplyProps) => { const chatApiService = useInjectable(ChatServiceToken); const chatAgentService = useInjectable(IChatAgentService); const chatRenderRegistry = useInjectable(ChatRenderRegistryToken); + const expandedThinkingIndexSetKey = request.requestId; + const expandedThinkingIndexSetRef = useRef>( + new Set(expandedThinkingIndexSetMap.get(expandedThinkingIndexSetKey)), + ); const [collapseThinkingIndexSet, setCollapseThinkingIndexSet] = useState>( - !request.response.isComplete + (!request.response.isComplete && !collapseReasoningByDefault) || + (keepReasoningExpandedOnComplete && !collapseReasoningByDefault) ? new Set() - : new Set( - request.response.responseContents - .map((item, index) => (item.kind === 'reasoning' ? index : -1)) - .filter((item) => item !== -1), - ), + : getReasoningIndexSet(request.response.responseContents, expandedThinkingIndexSetRef.current), ); useEffect(() => { - if (request.response.isComplete) { + if (request.response.isComplete && !keepReasoningExpandedOnComplete && !collapseReasoningByDefault) { setCollapseThinkingIndexSet( - new Set( - request.response.responseContents - .map((item, index) => (item.kind === 'reasoning' ? index : -1)) - .filter((item) => item !== -1), - ), + getReasoningIndexSet(request.response.responseContents, expandedThinkingIndexSetRef.current), ); } - }, [request.response.isComplete]); + }, [request.response.isComplete, keepReasoningExpandedOnComplete, collapseReasoningByDefault]); useEffect(() => { const disposableCollection = new DisposableCollection(); @@ -283,9 +293,10 @@ export const ChatReply = (props: IChatReplyProps) => { }, [relationId, onDidChange, onDone]); const handleRegenerate = useCallback(() => { + expandedThinkingIndexSetMap.delete(expandedThinkingIndexSetKey); request.response.reset(); onRegenerate?.(); - }, [onRegenerate]); + }, [expandedThinkingIndexSetKey, onRegenerate, request.response]); const renderMarkdown = useCallback( (markdown: IMarkdownString) => { @@ -323,6 +334,10 @@ export const ChatReply = (props: IChatReplyProps) => { } else if (item.kind === 'reasoning') { // 思考中必然为最后一条 const isThinking = index === request.response.responseContents.length - 1 && !request.response.isComplete; + const canToggleThinking = !isThinking || collapseReasoningByDefault; + const isCollapsed = + collapseThinkingIndexSet.has(index) || + (collapseReasoningByDefault && !expandedThinkingIndexSetRef.current.has(index)); node = (
- {!collapseThinkingIndexSet.has(index) ? ( + {!isCollapsed ? (
{renderMarkdown(new MarkdownString(item.content))}
) : null}
@@ -361,7 +389,12 @@ export const ChatReply = (props: IChatReplyProps) => { } return {node}; }), - [request.response.responseContents, collapseThinkingIndexSet], + [ + request.response.responseContents, + request.response.isComplete, + collapseReasoningByDefault, + collapseThinkingIndexSet, + ], ); const followupNode = React.useMemo(() => { diff --git a/packages/ai-native/src/browser/components/ChatToolResult.tsx b/packages/ai-native/src/browser/components/ChatToolResult.tsx index 7443bb35f2..8192a33f03 100644 --- a/packages/ai-native/src/browser/components/ChatToolResult.tsx +++ b/packages/ai-native/src/browser/components/ChatToolResult.tsx @@ -21,7 +21,13 @@ export const ChatToolResult: React.FC = ({ result, relation const parseResult = React.useCallback((resultStr: string): ResultContent[] => { try { const parsed = JSON.parse(resultStr); - return parsed.content || []; + if (Array.isArray(parsed)) { + return parsed as ResultContent[]; + } + if (parsed && Array.isArray(parsed.content)) { + return parsed.content as ResultContent[]; + } + return [{ type: 'text', text: resultStr }]; } catch (error) { return [{ type: 'text', text: resultStr }]; } diff --git a/packages/ai-native/src/browser/components/acp/MentionInput.tsx b/packages/ai-native/src/browser/components/acp/MentionInput.tsx index 7b9ed6ef2b..7689dfb57e 100644 --- a/packages/ai-native/src/browser/components/acp/MentionInput.tsx +++ b/packages/ai-native/src/browser/components/acp/MentionInput.tsx @@ -23,16 +23,119 @@ import { PermissionDialogWidget } from '../permission-dialog-widget'; import styles from './mention-input.module.less'; import { ModeOption } from './types'; +import type { AcpSessionConfigOption } from '../../chat/session-provider'; + export const WHITE_SPACE_TEXT = ' '; +interface NormalizedConfigOption { + id: string; + name: string; + description?: string; + currentValue: string; + isBoolean: boolean; + options: ExtendedModelOption[]; +} + +function readConfigId(option: AcpSessionConfigOption): string | undefined { + const rawId = option.id || option.configId; + if (typeof rawId === 'string') { + return rawId; + } + if (rawId && typeof rawId === 'object' && typeof (rawId as { id?: unknown }).id === 'string') { + return (rawId as { id: string }).id; + } + return undefined; +} + +function readConfigCurrentValue(option: AcpSessionConfigOption): boolean | string | undefined { + const kind = option.kind && typeof option.kind === 'object' ? option.kind : undefined; + const value = kind?.currentValue ?? option.currentValue ?? option.current_value ?? option.value; + return typeof value === 'boolean' || typeof value === 'string' ? value : undefined; +} + +function isBooleanConfig(option: AcpSessionConfigOption): boolean { + const kind = option.kind; + return ( + kind === 'boolean' || + option.type === 'boolean' || + (kind && typeof kind === 'object' && kind.type === 'boolean') || + typeof readConfigCurrentValue(option) === 'boolean' + ); +} + +function readConfigValueOptions(option: AcpSessionConfigOption): ExtendedModelOption[] { + if (isBooleanConfig(option)) { + return [ + { label: 'On', value: 'true' }, + { label: 'Off', value: 'false' }, + ]; + } + + const roots = [ + option.kind && typeof option.kind === 'object' ? option.kind.options : undefined, + option.options, + option.values, + ]; + const values = roots.find((root) => Array.isArray(root)) || []; + + return values + .map((item: any) => { + const value = item?.value ?? item?.id; + if (typeof value !== 'string') { + return undefined; + } + const label = item?.name || item?.label || value; + return { + label, + value, + description: item?.description || undefined, + }; + }) + .filter(Boolean) as ExtendedModelOption[]; +} + +function normalizeConfigOptions(configOptions?: AcpSessionConfigOption[]): NormalizedConfigOption[] { + return (configOptions || []) + .map((option) => { + const id = readConfigId(option); + if (!id) { + return undefined; + } + + const options = readConfigValueOptions(option); + if (options.length === 0) { + return undefined; + } + + const rawCurrentValue = readConfigCurrentValue(option); + const currentValue = + rawCurrentValue === undefined + ? options[0].value + : typeof rawCurrentValue === 'boolean' + ? String(rawCurrentValue) + : rawCurrentValue; + return { + id, + name: option.name || option.label || id, + description: option.description, + currentValue, + isBoolean: isBooleanConfig(option), + options: options.map((item) => ({ ...item, selected: item.value === currentValue })), + }; + }) + .filter(Boolean) as NormalizedConfigOption[]; +} + export const MentionInput: React.FC< MentionInputProps & { defaultInput?: string; onDefaultInputConsumed?: () => void; onModeChange?: (modeId: string) => void; + onConfigOptionChange?: (configId: string, value: boolean | string) => void; onAgentChange?: (agentId: string) => void; modeOptions?: ModeOption[]; currentMode?: string; + configOptions?: AcpSessionConfigOption[]; slashCommands?: Array<{ nameWithSlash: string; icon?: string; name?: string; description?: string }>; } > = ({ @@ -52,11 +155,14 @@ export const MentionInput: React.FC< showModelSelector: false, }, contextService, + expanded = false, defaultInput, onDefaultInputConsumed, onModeChange, + onConfigOptionChange, modeOptions, currentMode, + configOptions, slashCommands = [], }) => { const editorRef = React.useRef(null); @@ -466,12 +572,13 @@ export const MentionInput: React.FC< }); } - // 判断是否刚输入了 / + // 判断是否刚输入了 /(仅当 / 是第一个非空白字符时触发) if ( text[cursorPos - 1] === '/' && !mentionState.active && !mentionState.inlineSearchActive && - slashCommands.length > 0 + slashCommands.length > 0 && + text.substring(0, cursorPos - 1).trim() === '' ) { setMentionState({ active: true, @@ -624,7 +731,7 @@ export const MentionInput: React.FC< }); } - // 添加对 / 键的监听,支持在任意位置触发 slash command 菜单 + // 添加对 / 键的监听,仅当 / 是第一个非空白字符时触发 slash command 菜单 if ( e.key === '/' && !mentionState.active && @@ -633,6 +740,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, @@ -1295,6 +1409,18 @@ export const MentionInput: React.FC< [onModeChange], ); + const normalizedConfigOptions = React.useMemo( + () => normalizeConfigOptions(configOptions || footerConfig.configOptions), + [configOptions, footerConfig.configOptions], + ); + + const handleConfigOptionChange = React.useCallback( + (config: NormalizedConfigOption, value: string) => { + onConfigOptionChange?.(config.id, config.isBoolean ? value === 'true' : value); + }, + [onConfigOptionChange], + ); + // 修改 handleSend 函数 const handleSend = () => { if (!editorRef.current) { @@ -1342,15 +1468,21 @@ export const MentionInput: React.FC< setHistoryIndex(-1); setIsNavigatingHistory(false); } + let sendResult: unknown; if (onSend) { // 传递当前选择的模型和其他配置信息 - onSend(processedContent, { + sendResult = onSend(processedContent, { model: selectedModel, ...footerConfig, }); } editorRef.current.innerHTML = ''; + prevMentionTagsRef.current = []; + void Promise.resolve(sendResult).then( + () => contextService?.cleanFileContext(), + () => contextService?.cleanFileContext(), + ); // 重置编辑器高度和滚动条 if (editorRef.current) { @@ -1592,7 +1724,7 @@ export const MentionInput: React.FC< ); return ( -
+
{mentionState.active && (
@@ -1627,7 +1759,12 @@ export const MentionInput: React.FC< const Component = item.component; return ; })} + {renderButtons(FooterButtonPosition.LEFT)} + {renderContextPreview()} +
+
{footerConfig.showModelSelector && + normalizedConfigOptions.length === 0 && renderModelSelectorTip( 0 && + normalizedConfigOptions.length === 0 && renderModelSelectorTip( ({ @@ -1658,10 +1796,21 @@ export const MentionInput: React.FC< />, )} - {renderButtons(FooterButtonPosition.LEFT)} -
- {renderContextPreview()} -
+
+ {normalizedConfigOptions.map((config) => + renderModelSelectorTip( + handleConfigOptionChange(config, value)} + className={styles.config_selector} + size='small' + />, + ), + )} +
+ {footerItems .filter((item) => item.position === FooterButtonPosition.RIGHT) .map((item) => { @@ -1687,11 +1836,11 @@ export const MentionInput: React.FC< ) : ( )} 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..da5e905943 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 @@ -1,5 +1,100 @@ @import '../chat-history.module.less'; +.chat_history_header_bar { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + min-width: 0; +} + +.chat_history_inline { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 6px; + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; + padding: 8px 8px 8px 12px; + box-sizing: border-box; + + .chat_history_header_title { + gap: 6px; + opacity: 1; + } + + .chat_history_header_actions { + flex-shrink: 0; + } + + .chat_history_search { + box-sizing: border-box; + } + + .chat_history_inline_list { + flex: 1 1 auto; + width: 100%; + max-height: none; + min-height: 0; + box-sizing: border-box; + padding: 2px 0 6px; + overflow-y: auto; + } + + .chat_history_item { + padding: 6px 8px; + border-radius: 4px; + } + + .chat_history_item_content { + min-width: 0; + } + + .chat_history_item_title { + min-width: 0; + } +} + +.chat_history_header_inline_actions { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +.chat_history_header_actions_collapse { + cursor: pointer; +} + +.chat_history_header_actions_mcp { + cursor: pointer; +} + +.chat_history_inline_content { + display: flex; + flex-direction: column; + min-width: 0; + flex: 1 1 auto; + min-height: 0; + overflow: hidden; +} + +.pending_permission_badge_inline { + 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; +} + .chat_history_list_disabled { pointer-events: none; opacity: 0.5; @@ -16,3 +111,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/acp/chat-input-validation.ts b/packages/ai-native/src/browser/components/acp/chat-input-validation.ts new file mode 100644 index 0000000000..0040330018 --- /dev/null +++ b/packages/ai-native/src/browser/components/acp/chat-input-validation.ts @@ -0,0 +1,32 @@ +const CONTENT_REFERENCE_PATTERN = /\{\{@(?:file|folder|code|rule):[^}]+\}\}/i; +const CONTENT_EDITABLE_PAYLOAD_ATTRIBUTE_PATTERN = /\sdata-(?:context-id|command)=["'][^"']+["']/i; +const CONTENT_EDITABLE_EMPTY_HTML_PATTERN = + /^(?:(?:\s| | |�*a0;|\u00a0|\u200b|\u200c|\u200d|\ufeff)+||<\/?(?:div|p|span)[^>]*>)*$/i; + +function hasAttachmentPayload(images?: readonly unknown[]): boolean { + return Array.isArray(images) && images.some(Boolean); +} + +export function hasChatInputTextPayload(message?: string): boolean { + if (typeof message !== 'string') { + return false; + } + + if (CONTENT_REFERENCE_PATTERN.test(message) || CONTENT_EDITABLE_PAYLOAD_ATTRIBUTE_PATTERN.test(message)) { + return true; + } + + return !CONTENT_EDITABLE_EMPTY_HTML_PATTERN.test(message); +} + +export function hasAcpChatSendPayload({ + message, + images, + command, +}: { + message?: string; + images?: readonly unknown[]; + command?: string; +}): boolean { + return hasAttachmentPayload(images) || Boolean(command?.trim()) || hasChatInputTextPayload(message); +} diff --git a/packages/ai-native/src/browser/components/acp/mention-input.module.less b/packages/ai-native/src/browser/components/acp/mention-input.module.less index 82a262ec30..97a559dcac 100644 --- a/packages/ai-native/src/browser/components/acp/mention-input.module.less +++ b/packages/ai-native/src/browser/components/acp/mention-input.module.less @@ -18,18 +18,57 @@ } .mode_selector { - margin-right: 5px; + margin-right: 0; +} + +.config_selector { + margin-right: 0; +} + +.model_selector { + margin-right: 0; +} + +.config_controls { + display: flex; + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; + gap: 8px; + min-width: 0; } .left_control { - flex: 0 0 auto !important; + flex: 1 1 auto !important; + gap: 8px; + min-width: 0; +} + +.right_control { + margin-left: auto; + gap: 8px; + flex: 0 1 auto; + flex-wrap: wrap; + justify-content: flex-end; + min-width: 0; +} + +.model_selector, +.mode_selector, +.config_selector { + max-width: 180px; +} + +.footer { + flex-wrap: wrap; + row-gap: 8px; } .context_preview_container { - margin: 0 4px; + margin: 0; margin-bottom: 0; width: auto; - flex: 1 1 auto; + flex: 0 1 auto; background: none; border: none; padding: 0; 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..41a4475a82 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; @@ -132,10 +148,6 @@ display: none; } - .chat_history_item_selected { - background: var(--textPreformat-background); - } - &:hover { background: var(--textPreformat-background); @@ -146,6 +158,12 @@ max-width: calc(100% - 50px); } } + + &.chat_history_item_selected, + &.chat_history_item_selected:hover { + background: var(--list-activeSelectionBackground, var(--textPreformat-background)); + color: var(--list-activeSelectionForeground, inherit); + } } svg { @@ -154,3 +172,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/components.module.less b/packages/ai-native/src/browser/components/components.module.less index be74789605..f245cda4bd 100644 --- a/packages/ai-native/src/browser/components/components.module.less +++ b/packages/ai-native/src/browser/components/components.module.less @@ -5,8 +5,9 @@ position: absolute; bottom: -15px; padding-top: 12px; - left: -9px; - width: 105%; + left: 0; + right: 0; + overflow: hidden; } .block { @@ -102,6 +103,21 @@ border-color: var(--design-inputOption-activeForeground); } + &.chat_input_container_expanded { + height: 70vh; + min-height: 0; + display: flex; + flex-direction: column; + } + + .chat_input_body { + min-height: 0; + } + + &.chat_input_container_expanded .chat_input_body { + flex: 1 1 auto; + } + .theme_container { padding: 8px 12px 2px; display: flex; diff --git a/packages/ai-native/src/browser/components/mention-input/mention-input.module.less b/packages/ai-native/src/browser/components/mention-input/mention-input.module.less index 9ea11c52b7..4c5f94539e 100644 --- a/packages/ai-native/src/browser/components/mention-input/mention-input.module.less +++ b/packages/ai-native/src/browser/components/mention-input/mention-input.module.less @@ -132,6 +132,30 @@ } } +.input_container_expanded { + height: 100%; + min-height: 0; + display: flex; + flex-direction: column; + + .editor_area { + flex: 1 1 auto; + min-height: 0; + display: flex; + } + + .editor { + height: 100%; + max-height: none; + overflow-y: auto; + box-sizing: border-box; + } + + .footer { + flex: 0 0 auto; + } +} + .mention_panel_container { position: absolute; top: -20px; diff --git a/packages/ai-native/src/browser/components/mention-input/types.ts b/packages/ai-native/src/browser/components/mention-input/types.ts index 3fcdd3fb89..26426da8e2 100644 --- a/packages/ai-native/src/browser/components/mention-input/types.ts +++ b/packages/ai-native/src/browser/components/mention-input/types.ts @@ -104,6 +104,7 @@ export interface FooterConfig { showThinking?: boolean; thinkingEnabled?: boolean; onThinkingChange?: (enabled: boolean) => void; + configOptions?: Record[]; } export interface MentionInputProps { @@ -120,6 +121,7 @@ export interface MentionInputProps { labelService?: LabelService; workspaceService?: IWorkspaceService; contextService?: LLMContextService; + expanded?: boolean; } export const MENTION_KEYWORD = '@'; 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..fca55b6585 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,22 @@ export const PermissionDialogWidget: React.FC = ({ permissionBridgeService.handleDialogClose(current.requestId); dialogManager.removeDialog(current.requestId); }} + data-testid='acp-permission-dialog-close' + aria-label='Close permission dialog' >
{/* 内容 */} - {shouldShowContent && params.content &&
{params.content}
} + {shouldShowContent && params.content && ( +
+ {params.content} +
+ )} {/* 选项 */} -
+
{(params.options || []).map((option, index) => { const isFocused = focusedIndex === index; return ( @@ -130,6 +152,10 @@ export const PermissionDialogWidget: React.FC = ({ dialogManager.removeDialog(current.requestId); }} onMouseEnter={() => setFocusedIndex(index)} + data-testid={`acp-permission-dialog-option-${index}`} + data-option-id={option.optionId} + data-option-kind={option.kind} + aria-label={`Permission option ${option.name || option.optionId}`} > {index + 1} {option.name || option.optionId} diff --git a/packages/ai-native/src/browser/context/llm-context.service.ts b/packages/ai-native/src/browser/context/llm-context.service.ts index e57853c22d..2b0de10660 100644 --- a/packages/ai-native/src/browser/context/llm-context.service.ts +++ b/packages/ai-native/src/browser/context/llm-context.service.ts @@ -1,7 +1,7 @@ import { Autowired, Injectable } from '@opensumi/di'; import { PreferenceService } from '@opensumi/ide-core-browser'; import { AppConfig } from '@opensumi/ide-core-browser/lib/react-providers/config-provider'; -import { AINativeSettingSectionsId, IApplicationService, RulesServiceToken } from '@opensumi/ide-core-common'; +import { AINativeSettingSectionsId, IApplicationService, ILogger, RulesServiceToken } from '@opensumi/ide-core-common'; import { WithEventBus } from '@opensumi/ide-core-common/lib/event-bus/event-decorator'; import { MarkerSeverity } from '@opensumi/ide-core-common/lib/types/markers/markers'; import { Emitter, OperatingSystem, URI, parseGlob } from '@opensumi/ide-core-common/lib/utils'; @@ -39,6 +39,9 @@ export class LLMContextServiceImpl extends WithEventBus implements LLMContextSer @Autowired(RulesServiceToken) protected readonly rulesService: RulesService; + @Autowired(ILogger) + protected readonly logger: ILogger; + @Autowired(PreferenceService) protected readonly preferenceService: PreferenceService; @@ -267,15 +270,52 @@ export class LLMContextServiceImpl extends WithEventBus implements LLMContextSer } async serialize(): Promise { + const startTime = Date.now(); const files = this.getAllContextFiles(); const workspaceRoot = URI.file(this.appConfig.workspaceDir); + this.logger.log( + `[LLMContextService] serialize start — viewed=${files.viewed.length}, attached=${files.attached.length}, attachedFolders=${files.attachedFolders.length}, attachedRules=${files.attachedRules.length}`, + ); + + const recentlyViewFiles = this.serializeRecentlyViewFiles(files.viewed, workspaceRoot); + this.logger.log( + `[LLMContextService] serialize recentlyViewFiles done — count=${recentlyViewFiles.length}, elapsedMs=${ + Date.now() - startTime + }`, + ); + + const attachedFiles = this.serializeAttachedFiles(files.attached, workspaceRoot); + this.logger.log( + `[LLMContextService] serialize attachedFiles done — count=${attachedFiles.length}, elapsedMs=${ + Date.now() - startTime + }`, + ); + + const attachedFolders = await this.serializeAttachedFolders(files.attachedFolders); + this.logger.log( + `[LLMContextService] serialize attachedFolders done — count=${attachedFolders.length}, elapsedMs=${ + Date.now() - startTime + }`, + ); + + const attachedRules = this.serializeAttachedRules(files.attachedRules); + this.logger.log( + `[LLMContextService] serialize attachedRules done — count=${attachedRules.length}, elapsedMs=${ + Date.now() - startTime + }`, + ); + + const globalRules = this.serializeGlobalRules(); + this.logger.log( + `[LLMContextService] serialize done — globalRules=${globalRules.length}, elapsedMs=${Date.now() - startTime}`, + ); return { - recentlyViewFiles: this.serializeRecentlyViewFiles(files.viewed, workspaceRoot), - attachedFiles: this.serializeAttachedFiles(files.attached, workspaceRoot), - attachedFolders: await this.serializeAttachedFolders(files.attachedFolders), - attachedRules: this.serializeAttachedRules(files.attachedRules), - globalRules: this.serializeGlobalRules(), + recentlyViewFiles, + attachedFiles, + attachedFolders, + attachedRules, + globalRules, }; } @@ -287,7 +327,11 @@ export class LLMContextServiceImpl extends WithEventBus implements LLMContextSer folderPath.map(async (folder) => { const folderUri = new URI(folder); const absolutePath = folderUri.codeUri.fsPath; + this.logger.log(`[LLMContextService] serializeAttachedFolders folder start — path=${absolutePath}`); const folderStructure = await this.getFormattedFolderStructure(absolutePath); + this.logger.log( + `[LLMContextService] serializeAttachedFolders folder done — path=${absolutePath}, structureChars=${folderStructure.length}`, + ); return `Folder: ${absolutePath} Contents of directory: @@ -304,7 +348,13 @@ ${folderStructure}`; private async getFormattedFolderStructure(folder: string): Promise { const result: string[] = []; try { + const startTime = Date.now(); const stat = await this.fileService.getFileStat(folder); + this.logger.log( + `[LLMContextService] getFormattedFolderStructure stat done — path=${folder}, children=${ + stat?.children?.length ?? 0 + }, elapsedMs=${Date.now() - startTime}`, + ); for (const child of stat?.children || []) { const relativePath = new URI(folder).relative(new URI(child.uri))!.toString(); @@ -330,6 +380,7 @@ ${folderStructure}`; } } } catch { + this.logger.warn(`[LLMContextService] getFormattedFolderStructure failed — path=${folder}`); return ''; } diff --git a/packages/ai-native/src/browser/index.ts b/packages/ai-native/src/browser/index.ts index 3603a2cdb1..63ccd0f60b 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,8 +48,17 @@ 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 { + AcpChatRelayStore, + AcpChatRelaySummaryProvider, + AcpPermissionBridgeService, + AcpPermissionRpcService, + AcpThreadStatusRpcService, + AcpWebMcpRpcService, + WebMcpGroupRegistry, +} from './acp'; import { AcpFooterContribution } from './acp/components/AcpFooterContribution'; +import { AcpDebugLogContribution } from './acp/debug-log/acp-debug-log.contribution'; import { AcpPermissionDialogContribution, PermissionDialogManager } from './acp/permission-dialog-container'; import { AINativeBrowserContribution } from './ai-core.contribution'; import { AcpChatAgent } from './chat/acp-chat-agent'; @@ -85,6 +97,7 @@ import { RenameCandidatesProviderRegistry } from './contrib/rename/rename.featur import { TerminalAIContribution } from './contrib/terminal/terminal-ai.contributon'; import { TerminalFeatureRegistry } from './contrib/terminal/terminal.feature.registry'; import { LanguageParserService } from './languages/service'; +import { AIPanelLayoutService } from './layout/panel-layout.service'; import { BaseApplyService } from './mcp/base-apply.service'; import { MCPConfigCommandContribution } from './mcp/config/mcp-config.commands'; import { MCPConfigContribution } from './mcp/config/mcp-config.contribution'; @@ -131,9 +144,13 @@ export class AINativeModule extends BrowserModule { MCPConfigContribution, MCPConfigCommandContribution, MCPPreferencesContribution, + AcpDebugLogContribution, AcpPermissionDialogContribution, PermissionDialogManager, AcpPermissionBridgeService, + AcpChatRelayStore, + AcpChatRelaySummaryProvider, + AIPanelLayoutService, { token: ISessionProviderRegistry, useClass: SessionProviderRegistry, @@ -323,6 +340,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 +373,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/layout/ai-layout.tsx b/packages/ai-native/src/browser/layout/ai-layout.tsx index 1929c6353c..a5d23bca15 100644 --- a/packages/ai-native/src/browser/layout/ai-layout.tsx +++ b/packages/ai-native/src/browser/layout/ai-layout.tsx @@ -1,11 +1,26 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; -import { SlotLocation, SlotRenderer, useInjectable } from '@opensumi/ide-core-browser'; +import { + IClientApp, + PreferenceService, + SlotLocation, + SlotRenderer, + runWhenIdle, + useInjectable, +} from '@opensumi/ide-core-browser'; import { BoxPanel, SplitPanel, getStorageValue } from '@opensumi/ide-core-browser/lib/components'; import { DesignLayoutConfig } from '@opensumi/ide-core-browser/lib/layout/constants'; +import { PanelLayoutMode } from '@opensumi/ide-core-common'; +import { IMainLayoutService } from '@opensumi/ide-main-layout'; import { AI_CHAT_VIEW_ID } from '../../common'; +import { AIPanelLayoutService, getAIChatDefaultSize, getPanelLayoutStorageKey } from './panel-layout.service'; + +const AGENTIC_EDITOR_MIN_SIZE = 360; +const AGENTIC_WORKBENCH_MIN_RESIZE = 640; +const SIDE_SLOT_MAX_RESIZE = 480; + // 使用 UA 判断是否为移动设备 const isMobileDevice = () => { if (typeof navigator === 'undefined') { @@ -14,7 +29,7 @@ const isMobileDevice = () => { return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); }; -export const AILayout = () => { +export const ClassicShell = () => { const { layout } = getStorageValue(); const designLayoutConfig = useInjectable(DesignLayoutConfig); @@ -35,7 +50,6 @@ export const AILayout = () => { ); } - // 正常模式:渲染完整布局 const defaultRightSize = useMemo( () => (designLayoutConfig.useMergeRightWithLeftPanel ? 0 : 49), [designLayoutConfig.useMergeRightWithLeftPanel], @@ -99,3 +113,160 @@ export const AILayout = () => { ); }; + +export const AgenticShell = () => { + const layoutService = useInjectable(IMainLayoutService); + const clientApp = useInjectable(IClientApp); + const didDefaultOpenAIChat = useRef(false); + const { layout } = getStorageValue(getPanelLayoutStorageKey('agentic')); + + useEffect(() => { + layoutService.setLayoutStateKey(getPanelLayoutStorageKey('agentic'), { saveCurrent: false }); + }, [layoutService]); + + const aiChatLayout = layout[AI_CHAT_VIEW_ID]; + const shouldDefaultOpenAIChat = !aiChatLayout?.currentId; + const defaultAIChatSize = getAIChatDefaultSize('agentic'); + + const getSideSlotSize = (slot: SlotLocation, activeFallbackSize: number, inactiveFallbackSize: number) => { + const slotLayout = layout[slot]; + if (!slotLayout?.currentId) { + return inactiveFallbackSize; + } + + return Math.min(slotLayout.size || activeFallbackSize, SIDE_SLOT_MAX_RESIZE); + }; + + useEffect(() => { + if (!shouldDefaultOpenAIChat || didDefaultOpenAIChat.current) { + return; + } + + didDefaultOpenAIChat.current = true; + let disposed = false; + const aiChatReady = layoutService.getTabbarService(AI_CHAT_VIEW_ID).viewReady.promise; + Promise.all([clientApp.appInitialized.promise, aiChatReady]).then(() => { + runWhenIdle(() => { + if (!disposed) { + layoutService.toggleSlot(AI_CHAT_VIEW_ID, true, defaultAIChatSize); + } + }); + }); + + return () => { + disposed = true; + }; + }, [clientApp, defaultAIChatSize, layoutService, shouldDefaultOpenAIChat]); + + const aiChatSlot = ( + + ); + + const editorWithBottomPanel = (id: string) => ( + + + + + ); + + const workbenchViewSlot = ( + + ); + + const workbench = ( + + {[editorWithBottomPanel('main-vertical-agentic'), workbenchViewSlot]} + + ); + + return ( + + + + {[aiChatSlot, workbench]} + + + + ); +}; + +export const AIShellRoot = () => { + const panelLayoutService = useInjectable(AIPanelLayoutService); + const preferenceService = useInjectable(PreferenceService); + const [panelLayout, setPanelLayout] = useState(); + + useEffect(() => { + let disposed = false; + const disposable = panelLayoutService.onDidChangePanelLayout((mode) => { + if (!disposed) { + setPanelLayout(mode); + } + }); + + preferenceService.ready.then(() => { + if (!disposed) { + setPanelLayout(panelLayoutService.getLayoutMode()); + } + }); + + return () => { + disposed = true; + disposable.dispose(); + }; + }, [panelLayoutService, preferenceService]); + + if (!panelLayout) { + return null; + } + + return panelLayout === 'agentic' ? : ; +}; + +export const AILayout = AIShellRoot; diff --git a/packages/ai-native/src/browser/layout/layout.module.less b/packages/ai-native/src/browser/layout/layout.module.less index 4090b9d5e8..c2b074a3d0 100644 --- a/packages/ai-native/src/browser/layout/layout.module.less +++ b/packages/ai-native/src/browser/layout/layout.module.less @@ -173,3 +173,20 @@ .AI-Chat-slot { background-color: var(--activityBar-background) !important; } + +.agentic_view_slot { + :global(.kt-tab-panel) { + border-left: 1px solid var(--sideBar-border); + border-right: none !important; + } +} + +.agentic_view_tab_bar { + display: flex; + height: 100%; + + :global(#opensumi-left-tabbar) { + border-left: 1px solid var(--activityBar-border); + border-right: none !important; + } +} diff --git a/packages/ai-native/src/browser/layout/panel-layout.service.ts b/packages/ai-native/src/browser/layout/panel-layout.service.ts new file mode 100644 index 0000000000..c70ce5849e --- /dev/null +++ b/packages/ai-native/src/browser/layout/panel-layout.service.ts @@ -0,0 +1,160 @@ +import { Autowired, Injectable } from '@opensumi/di'; +import { IContextKeyService, PreferenceService, fastdom } from '@opensumi/ide-core-browser'; +import { DesignLayoutConfig } from '@opensumi/ide-core-browser/lib/layout/constants'; +import { LAYOUT_STATE } from '@opensumi/ide-core-browser/lib/layout/layout-state'; +import { AINativeSettingSectionsId, Emitter, PanelLayoutMode, PreferenceScope } from '@opensumi/ide-core-common'; +import { IMainLayoutService } from '@opensumi/ide-main-layout'; + +import { AI_CHAT_VIEW_ID } from '../../common'; + +export const AI_PANEL_LAYOUT_CONTEXT = 'aiNative.panelLayout'; +export const AI_PANEL_LAYOUT_MENU = 'aiNative/panelLayout'; +export const AI_AGENTIC_LAYOUT_STORAGE_KEY = 'layout.ai.agentic'; +export const AI_AGENTIC_CHAT_DEFAULT_SIZE = 840; +export const AI_CLASSIC_CHAT_DEFAULT_SIZE = 360; +const AI_CLASSIC_CHAT_MAX_SIZE = 1080; + +export const DEFAULT_AI_PANEL_LAYOUT: PanelLayoutMode = 'classic'; + +export function normalizePanelLayoutMode(value: unknown): PanelLayoutMode { + return value === 'classic' || value === 'agentic' ? value : DEFAULT_AI_PANEL_LAYOUT; +} + +export function getPanelLayoutStorageKey(mode: PanelLayoutMode): string { + return normalizePanelLayoutMode(mode) === 'agentic' ? AI_AGENTIC_LAYOUT_STORAGE_KEY : LAYOUT_STATE.MAIN; +} + +export function getAIChatDefaultSize(mode: PanelLayoutMode): number { + return normalizePanelLayoutMode(mode) === 'agentic' ? AI_AGENTIC_CHAT_DEFAULT_SIZE : AI_CLASSIC_CHAT_DEFAULT_SIZE; +} + +@Injectable() +export class AIPanelLayoutService { + @Autowired(PreferenceService) + private readonly preferenceService: PreferenceService; + + @Autowired(DesignLayoutConfig) + private readonly designLayoutConfig: DesignLayoutConfig; + + @Autowired(IContextKeyService) + private readonly contextKeyService: IContextKeyService; + + @Autowired(IMainLayoutService) + private readonly layoutService: IMainLayoutService; + + private readonly onDidChangePanelLayoutEmitter = new Emitter(); + readonly onDidChangePanelLayout = this.onDidChangePanelLayoutEmitter.event; + + private panelLayoutContextKey?: ReturnType; + private initialized = false; + private isSettingLayoutMode = false; + + initialize(): void { + if (this.initialized) { + return; + } + this.initialized = true; + void this.preferenceService.ready.then(() => { + const initialMode = this.getLayoutMode(); + this.applyLayoutMode(initialMode, false); + this.updateContextKey(initialMode); + this.onDidChangePanelLayoutEmitter.fire(initialMode); + }); + this.preferenceService.onSpecificPreferenceChange(AINativeSettingSectionsId.PanelLayout, () => { + if (this.isSettingLayoutMode) { + return; + } + const mode = this.getLayoutMode(); + this.activateLayoutMode(mode, true); + }); + } + + getLayoutMode(): PanelLayoutMode { + const inspected = this.preferenceService.inspect(AINativeSettingSectionsId.PanelLayout); + const configuredValue = + inspected?.workspaceFolderValue ?? + inspected?.workspaceValue ?? + inspected?.globalValue ?? + this.designLayoutConfig.panelLayout; + + return normalizePanelLayoutMode(configuredValue); + } + + async setLayoutMode(mode: PanelLayoutMode): Promise { + const normalizedMode = normalizePanelLayoutMode(mode); + this.isSettingLayoutMode = true; + try { + await this.preferenceService.set(AINativeSettingSectionsId.PanelLayout, normalizedMode, PreferenceScope.User); + } finally { + this.isSettingLayoutMode = false; + } + this.activateLayoutMode(this.getLayoutMode(), true); + } + + async toggleLayoutMode(): Promise { + await this.setLayoutMode(this.getLayoutMode() === 'agentic' ? 'classic' : 'agentic'); + } + + private updateContextKey(mode: PanelLayoutMode): void { + if (!this.panelLayoutContextKey) { + this.panelLayoutContextKey = this.contextKeyService.createKey(AI_PANEL_LAYOUT_CONTEXT, mode); + return; + } + this.panelLayoutContextKey.set(mode); + } + + private applyLayoutMode(mode: PanelLayoutMode, saveCurrent = true): void { + this.layoutService.setLayoutStateKey(getPanelLayoutStorageKey(mode), { saveCurrent }); + } + + private getAIChatOpenSize(mode: PanelLayoutMode): number { + const normalizedMode = normalizePanelLayoutMode(mode); + if (normalizedMode === 'agentic') { + return getAIChatDefaultSize(normalizedMode); + } + + const prevSize = this.layoutService.getTabbarService(AI_CHAT_VIEW_ID).prevSize; + if (typeof prevSize === 'number' && Number.isFinite(prevSize) && prevSize > 0) { + return Math.min(prevSize, AI_CLASSIC_CHAT_MAX_SIZE); + } + + return getAIChatDefaultSize(normalizedMode); + } + + showAIChatView(mode: PanelLayoutMode = this.getLayoutMode()): void { + const normalizedMode = normalizePanelLayoutMode(mode); + this.layoutService.toggleSlot(AI_CHAT_VIEW_ID, true, this.getAIChatOpenSize(normalizedMode)); + } + + toggleAIChatView(mode: PanelLayoutMode = this.getLayoutMode()): void { + const normalizedMode = normalizePanelLayoutMode(mode); + const isVisible = this.layoutService.isVisible(AI_CHAT_VIEW_ID); + this.layoutService.toggleSlot( + AI_CHAT_VIEW_ID, + undefined, + isVisible ? undefined : this.getAIChatOpenSize(normalizedMode), + ); + } + + private activateLayoutMode(mode: PanelLayoutMode, restoreAIChat = false): void { + this.applyLayoutMode(mode); + if (restoreAIChat) { + this.showAIChatView(mode); + this.restoreLayoutAfterModeChange(mode); + } + this.updateContextKey(mode); + this.onDidChangePanelLayoutEmitter.fire(mode); + } + + private restoreLayoutAfterModeChange(mode: PanelLayoutMode): void { + const layoutStateKey = getPanelLayoutStorageKey(mode); + + fastdom.measureAtNextFrame(() => { + this.showAIChatView(mode); + fastdom.measureAtNextFrame(() => { + this.layoutService.setLayoutStateKey(layoutStateKey, { saveCurrent: false, forceRestore: true }); + this.showAIChatView(mode); + }); + }); + } +} diff --git a/packages/ai-native/src/browser/layout/tabbar.view.tsx b/packages/ai-native/src/browser/layout/tabbar.view.tsx index 35bd63aedd..af1024d2ff 100644 --- a/packages/ai-native/src/browser/layout/tabbar.view.tsx +++ b/packages/ai-native/src/browser/layout/tabbar.view.tsx @@ -4,11 +4,13 @@ import React, { useCallback, useMemo } from 'react'; import { ComponentRegistryInfo, SlotLocation, + fastdom, useAutorun, useContextMenus, useInjectable, } from '@opensumi/ide-core-browser'; -import { EDirection } from '@opensumi/ide-core-browser/lib/components'; +import { EXPLORER_CONTAINER_ID, SCM_CONTAINER_ID } from '@opensumi/ide-core-browser/lib/common/container-id'; +import { EDirection, PanelContext, ResizeHandle } from '@opensumi/ide-core-browser/lib/components'; import { EnhanceIcon, EnhanceIconWithCtxMenu, @@ -16,6 +18,7 @@ import { HorizontalVertical, } from '@opensumi/ide-core-browser/lib/components/ai-native'; import { DesignLayoutConfig } from '@opensumi/ide-core-browser/lib/layout/constants'; +import { VIEW_CONTAINERS } from '@opensumi/ide-core-browser/lib/layout/view-id'; import { IMenu } from '@opensumi/ide-core-browser/lib/menu/next'; import { localize } from '@opensumi/ide-core-common'; import { DesignLeftTabRenderer, DesignRightTabRenderer } from '@opensumi/ide-design/lib/browser/layout/tabbar.view'; @@ -35,37 +38,137 @@ import { TabbarService, TabbarServiceFactory } from '@opensumi/ide-main-layout/l import { AI_CHAT_VIEW_ID } from '../../common'; import styles from './layout.module.less'; +import { AIPanelLayoutService } from './panel-layout.service'; -const ChatTabbarRenderer: React.FC = () => ( -
- +const AGENTIC_VIEW_ACTIVITY_BAR_SIZE = 49; +const AGENTIC_VIEW_DEFAULT_SIZE = 310; +const AGENTIC_VIEW_MAX_SIZE = 480; +const AGENTIC_VISIBLE_VIEW_CONTAINER_IDS = new Set([EXPLORER_CONTAINER_ID, SCM_CONTAINER_ID]); + +const isAgenticVisibleViewContainer = (component: ComponentRegistryInfo) => { + const containerId = component.options?.containerId; + return !!containerId && AGENTIC_VISIBLE_VIEW_CONTAINER_IDS.has(containerId); +}; + +const ChatTabbarRenderer: React.FC<{ disableAutoAdjust?: boolean }> = ({ disableAutoAdjust }) => ( +
+
); +function useFixedResizeSideHandle(enabled: boolean, targetIsLatter: boolean): ResizeHandle { + const resizeHandle = React.useContext(PanelContext); + + return React.useMemo(() => { + if (!enabled) { + return resizeHandle; + } + + return { + ...resizeHandle, + setSize: (targetSize?: number) => resizeHandle.setSize(targetSize, targetIsLatter), + setRelativeSize: (prev: number, next: number) => resizeHandle.setRelativeSize(prev, next, targetIsLatter), + getSize: () => resizeHandle.getSize(targetIsLatter), + getRelativeSize: () => resizeHandle.getRelativeSize(targetIsLatter), + lockSize: (lock: boolean | undefined) => resizeHandle.lockSize(lock, targetIsLatter), + setMaxSize: (lock: boolean | undefined) => resizeHandle.setMaxSize(lock, targetIsLatter), + }; + }, [enabled, resizeHandle, targetIsLatter]); +} + +function getAgenticViewRestoreSize(tabbarService: TabbarService): number { + const cachedSize = tabbarService.prevSize; + + if (typeof cachedSize === 'number' && Number.isFinite(cachedSize) && cachedSize > AGENTIC_VIEW_ACTIVITY_BAR_SIZE) { + return Math.min(cachedSize, AGENTIC_VIEW_MAX_SIZE); + } + + return AGENTIC_VIEW_DEFAULT_SIZE; +} + +function useRestoreAgenticViewSize( + tabbarService: TabbarService, + resizeHandle: ResizeHandle, + currentContainerId: string | undefined, +) { + React.useEffect(() => { + if (!currentContainerId) { + return; + } + + let disposed = false; + const frameDisposables: Array<{ dispose(): void }> = []; + + const restoreIfCollapsed = () => { + if (disposed) { + return; + } + + const frameDisposable = fastdom.measureAtNextFrame(() => { + if (disposed || !tabbarService.currentContainerId.get()) { + return; + } + + const currentSize = resizeHandle.getSize(true); + if (!Number.isFinite(currentSize) || currentSize <= AGENTIC_VIEW_ACTIVITY_BAR_SIZE) { + resizeHandle.setSize(getAgenticViewRestoreSize(tabbarService)); + } + }); + frameDisposables.push(frameDisposable); + }; + + restoreIfCollapsed(); + + void tabbarService.viewReady.promise.then(() => { + restoreIfCollapsed(); + }); + + return () => { + disposed = true; + frameDisposables.forEach((disposable) => disposable.dispose()); + }; + }, [currentContainerId, resizeHandle, tabbarService]); +} + export const AIChatTabRenderer = ({ className, components, }: { className: string; components: ComponentRegistryInfo[]; -}) => ( - } - TabpanelView={() => ( - - )} - /> -); +}) => { + const panelLayoutService = useInjectable(AIPanelLayoutService); + const isAgenticLayout = panelLayoutService.getLayoutMode() === 'agentic'; + const aiChatResizeHandle = useFixedResizeSideHandle(true, !isAgenticLayout); + + const renderer = ( + } + TabpanelView={() => ( + + )} + /> + ); + + return {renderer}; +}; export const AIChatTabRendererWithTab = ({ className, @@ -73,24 +176,32 @@ export const AIChatTabRendererWithTab = ({ }: { className: string; components: ComponentRegistryInfo[]; -}) => ( - } - TabpanelView={() => ( - - )} - /> -); +}) => { + const panelLayoutService = useInjectable(AIPanelLayoutService); + const isAgenticLayout = panelLayoutService.getLayoutMode() === 'agentic'; + const aiChatResizeHandle = useFixedResizeSideHandle(true, !isAgenticLayout); + + const renderer = ( + } + TabpanelView={() => ( + + )} + /> + ); + + return {renderer}; +}; export const AILeftTabRenderer = ({ className, @@ -98,12 +209,59 @@ export const AILeftTabRenderer = ({ }: { className: string; components: ComponentRegistryInfo[]; -}) => ; +}) => { + const panelLayoutService = useInjectable(AIPanelLayoutService); + const isAgenticLayout = panelLayoutService.getLayoutMode() === 'agentic'; + + if (!isAgenticLayout) { + return ; + } + + return ; +}; + +const AgenticLeftTabRenderer = ({ + className, + components, +}: { + className: string; + components: ComponentRegistryInfo[]; +}) => { + const viewTabbarService: TabbarService = useInjectable(TabbarServiceFactory)(SlotLocation.view); + const currentContainerId = useAutorun(viewTabbarService.currentContainerId); + const agenticResizeHandle = useFixedResizeSideHandle(true, true); + + useRestoreAgenticViewSize(viewTabbarService, agenticResizeHandle, currentContainerId); + + return ( + + ( +
+ +
+ )} + TabpanelView={() => } + /> +
+ ); +}; const AILeftTabbarRenderer: React.FC = () => { const layoutService = useInjectable(IMainLayoutService); + const panelLayoutService = useInjectable(AIPanelLayoutService); + const isAgenticLayout = panelLayoutService.getLayoutMode() === 'agentic'; - const tabbarService: TabbarService = useInjectable(TabbarServiceFactory)(SlotLocation.extendView); + // In Agentic layout, the tabbar and panel both render in SlotLocation.view, + // so they must share the same tabbar service. Using extendView here causes + // the panel (listening to `view`) to never see activations from the tabbar. + const activeSlot = isAgenticLayout ? SlotLocation.view : SlotLocation.extendView; + const tabbarService: TabbarService = useInjectable(TabbarServiceFactory)(activeSlot); const currentContainerId = useAutorun(tabbarService.currentContainerId); const extraMenus = React.useMemo(() => layoutService.getExtraMenu(), [layoutService]); @@ -111,7 +269,13 @@ const AILeftTabbarRenderer: React.FC = () => { const renderOtherVisibleContainers = useCallback( ({ renderContainers }) => { - const visibleContainers = tabbarService.visibleContainers.filter((container) => !container.options?.hideTab); + const visibleContainers = tabbarService.visibleContainers.filter((container) => { + if (container.options?.hideTab) { + return false; + } + + return !isAgenticLayout || isAgenticVisibleViewContainer(container); + }); return ( <> @@ -120,13 +284,14 @@ const AILeftTabbarRenderer: React.FC = () => { ); }, - [currentContainerId, tabbarService], + [currentContainerId, tabbarService, isAgenticLayout], ); return ( {navMenu.length >= 0 diff --git a/packages/ai-native/src/browser/layout/view/avatar/avatar.module.less b/packages/ai-native/src/browser/layout/view/avatar/avatar.module.less index 2b0f9b3f38..81acbca2f6 100644 --- a/packages/ai-native/src/browser/layout/view/avatar/avatar.module.less +++ b/packages/ai-native/src/browser/layout/view/avatar/avatar.module.less @@ -1,3 +1,15 @@ +.ai_actions { + display: flex; + align-items: center; + gap: 8px; +} + +.avatar_icon_large { + width: 16px; + height: 16px; + font-size: 16px !important; +} + .ai_switch { height: 16px; width: 16px; @@ -6,9 +18,11 @@ display: flex; align-items: center; justify-content: center; - .avatar_icon_large { - width: 16px; - height: 16px; - font-size: 16px !important; - } +} + +.layout_switch { + display: flex; + align-items: center; + justify-content: center; + min-width: 96px; } diff --git a/packages/ai-native/src/browser/layout/view/avatar/avatar.view.tsx b/packages/ai-native/src/browser/layout/view/avatar/avatar.view.tsx index dc67f15dba..068aeebfcf 100644 --- a/packages/ai-native/src/browser/layout/view/avatar/avatar.view.tsx +++ b/packages/ai-native/src/browser/layout/view/avatar/avatar.view.tsx @@ -1,23 +1,54 @@ import React from 'react'; -import { useInjectable } from '@opensumi/ide-core-browser'; +import { Select } from '@opensumi/ide-components'; +import { localize, useInjectable } from '@opensumi/ide-core-browser'; import { AILogoAvatar } from '@opensumi/ide-core-browser/lib/components/ai-native'; -import { IMainLayoutService } from '@opensumi/ide-main-layout'; +import { PanelLayoutMode } from '@opensumi/ide-core-common'; -import { AI_CHAT_VIEW_ID } from '../../../../common'; +import { AIPanelLayoutService } from '../../panel-layout.service'; import styles from './avatar.module.less'; export const AIChatLogoAvatar = () => { - const layoutService = useInjectable(IMainLayoutService); + const panelLayoutService = useInjectable(AIPanelLayoutService); + + const [layoutMode, setLayoutMode] = React.useState(() => panelLayoutService.getLayoutMode()); + + React.useEffect(() => { + setLayoutMode(panelLayoutService.getLayoutMode()); + const disposable = panelLayoutService.onDidChangePanelLayout((mode) => { + setLayoutMode(mode); + }); + return () => disposable.dispose(); + }, [panelLayoutService]); const handleChatVisible = React.useCallback(() => { - layoutService.toggleSlot(AI_CHAT_VIEW_ID); - }, [layoutService]); + panelLayoutService.toggleAIChatView(layoutMode); + }, [layoutMode, panelLayoutService]); + + const handleLayoutModeChange = React.useCallback( + (value: PanelLayoutMode) => { + void panelLayoutService.setLayoutMode(value); + }, + [panelLayoutService], + ); return ( -
- +
+
+ +
+
+ + size='small' + value={layoutMode} + onChange={handleLayoutModeChange} + options={[ + { label: localize('ai.native.layout.agentic'), value: 'agentic' }, + { label: localize('ai.native.layout.classic'), value: 'classic' }, + ]} + /> +
); }; diff --git a/packages/ai-native/src/browser/mcp/config/components/mcp-config.module.less b/packages/ai-native/src/browser/mcp/config/components/mcp-config.module.less index a18c726a65..40c8e5792c 100644 --- a/packages/ai-native/src/browser/mcp/config/components/mcp-config.module.less +++ b/packages/ai-native/src/browser/mcp/config/components/mcp-config.module.less @@ -235,3 +235,15 @@ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); } } + +.capabilityTag { + cursor: default; + + &:hover { + transform: none; + } +} + +.profileSelect { + min-width: 140px; +} diff --git a/packages/ai-native/src/browser/mcp/config/components/mcp-config.view.tsx b/packages/ai-native/src/browser/mcp/config/components/mcp-config.view.tsx index 4a50babfc5..c5e6969661 100644 --- a/packages/ai-native/src/browser/mcp/config/components/mcp-config.view.tsx +++ b/packages/ai-native/src/browser/mcp/config/components/mcp-config.view.tsx @@ -1,18 +1,21 @@ import cls from 'classnames'; import React, { useCallback } from 'react'; -import { Badge, Button, Icon, Popover, PopoverTriggerType } from '@opensumi/ide-components'; +import { Badge, Button, Icon, Popover, PopoverTriggerType, Select } from '@opensumi/ide-components'; import { useInjectable } from '@opensumi/ide-core-browser'; import { MCPConfigServiceToken, localize } from '@opensumi/ide-core-common'; import { BUILTIN_MCP_SERVER_NAME } from '../../../../common'; import { MCPServerDescription } from '../../../../common/mcp-server-manager'; import { MCPServer } from '../../../../common/types'; -import { MCPConfigService } from '../mcp-config.service'; +import { MCPConfigService, WEBMCP_PROFILE_OPTIONS } from '../mcp-config.service'; import styles from './mcp-config.module.less'; import { MCPServerForm, MCPServerFormData } from './mcp-server-form'; +import type { WebMcpGroupSummary } from '../mcp-config.service'; +import type { WebMcpProfile } from '@opensumi/ide-core-common/lib/types/ai-native/acp-types'; + export const MCPConfigView: React.FC = () => { const mcpConfigService = useInjectable(MCPConfigServiceToken); const [servers, setServers] = React.useState([]); @@ -21,6 +24,8 @@ export const MCPConfigView: React.FC = () => { const [loadingServer, setLoadingServer] = React.useState(); const [isReady, setIsReady] = React.useState(mcpConfigService.isInitialized); const [disabledTools, setDisabledTools] = React.useState([]); + const [webMcpProfile, setWebMcpProfile] = React.useState(mcpConfigService.getWebMcpProfile()); + const [webMcpGroups, setWebMcpGroups] = React.useState([]); const loadServers = useCallback(async () => { const allServers = await mcpConfigService.getServers(); @@ -32,20 +37,27 @@ export const MCPConfigView: React.FC = () => { setDisabledTools(disabled); }, [mcpConfigService]); + const loadWebMcpConfig = useCallback(() => { + setWebMcpProfile(mcpConfigService.getWebMcpProfile()); + setWebMcpGroups(mcpConfigService.getWebMcpGroups()); + }, [mcpConfigService]); + React.useEffect(() => { loadServers(); loadDisabledTools(); + loadWebMcpConfig(); const disposer = mcpConfigService.onMCPServersChange((isReady) => { if (isReady) { setIsReady(true); } loadServers(); + loadWebMcpConfig(); }); return () => { disposer.dispose(); }; - }, [loadServers, loadDisabledTools]); + }, [loadServers, loadDisabledTools, loadWebMcpConfig]); const handleServerControl = useCallback( async (serverName: string, start: boolean) => { @@ -53,12 +65,22 @@ export const MCPConfigView: React.FC = () => { setLoadingServer(serverName); await mcpConfigService.controlServer(serverName, start); await loadServers(); + loadWebMcpConfig(); setLoadingServer(undefined); } catch (error) { setLoadingServer(undefined); } }, - [mcpConfigService, loadServers], + [mcpConfigService, loadServers, loadWebMcpConfig], + ); + + const handleWebMcpProfileChange = useCallback( + async (profile: WebMcpProfile) => { + setWebMcpProfile(profile); + await mcpConfigService.setWebMcpProfile(profile); + loadWebMcpConfig(); + }, + [mcpConfigService, loadWebMcpConfig], ); const handleAddServer = useCallback(() => { @@ -138,124 +160,165 @@ export const MCPConfigView: React.FC = () => {
- {servers.map((server) => ( -
-
-
-

- {server.name} - -

-
-
- - + + {!isBuiltinServer && ( + - - {server.name !== BUILTIN_MCP_SERVER_NAME && ( -
+
+
+ {server.type && ( +
+ Type: + + {mcpConfigService.getReadableServerType(server.type)} + +
)}
-
-
- {server.type && ( -
- Type: - - {mcpConfigService.getReadableServerType(server.type)} - -
+ {isBuiltinServer && ( + <> +
+
+ Profile: + + size='small' + disabled={!server.isStarted} + value={webMcpProfile} + options={WEBMCP_PROFILE_OPTIONS.map((profile) => ({ label: profile, value: profile }))} + className={styles.profileSelect} + onChange={handleWebMcpProfileChange} + /> +
+
+ {webMcpGroups.length > 0 && ( +
+
+ Capabilities: + + {webMcpGroups.map((group) => ( + + {group.name} ({group.toolCount}) + + ))} + +
+
+ )} + )} -
- {server.tools && server.tools.length > 0 && ( -
-
- Tools: - - {server.tools.map((tool, index) => { - const isDisabled = disabledTools.includes(tool.name); - return ( - handleToggleTool(tool.name)} - style={{ cursor: 'pointer' }} - > - {tool.name} - {isDisabled && } - - ); - })} - + {!isBuiltinServer && server.tools && server.tools.length > 0 && ( +
+
+ Tools: + + {server.tools.map((tool, index) => { + const isDisabled = disabledTools.includes(tool.name); + return ( + handleToggleTool(tool.name)} + style={{ cursor: 'pointer' }} + > + {tool.name} + {isDisabled && } + + ); + })} + +
-
- )} - {server.command && ( -
-
- Command: - {server.command} + )} + {server.command && ( +
+
+ Command: + {server.command} +
-
- )} - {server.url && ( -
-
- Server Link: - {server.url} + )} + {server.url && ( +
+
+ Server Link: + {server.url} +
-
- )} -
- ))} + )} +
+ ); + })}
; 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..9d1e3f2285 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 @@ -2,6 +2,7 @@ import { Autowired, Injectable } from '@opensumi/di'; import { AppConfig, ILogger } from '@opensumi/ide-core-browser'; import { PreferenceService } from '@opensumi/ide-core-browser/lib/preferences'; import { + AINativeSettingSectionsId, Deferred, Disposable, Emitter, @@ -11,6 +12,7 @@ import { StorageProvider, localize, } from '@opensumi/ide-core-common'; +import { WebMcpGroupRegistryToken } from '@opensumi/ide-core-common/lib/types/ai-native/acp-types'; import { WorkbenchEditorService } from '@opensumi/ide-editor'; import { IMessageService } from '@opensumi/ide-overlay'; @@ -22,10 +24,27 @@ import { StdioMCPServerDescription, } from '../../../common/mcp-server-manager'; import { MCPServer, MCP_SERVER_TYPE } from '../../../common/types'; +import { WebMcpGroupRegistry } from '../../acp/webmcp-group-registry'; import { MCPServerProxyService } from '../mcp-server-proxy.service'; import { MCPServerFormData } from './components/mcp-server-form'; +import type { + EnvVariable, + McpServer, + WebMcpGroupDef, + WebMcpProfile, +} from '@opensumi/ide-core-common/lib/types/ai-native/acp-types'; + +export const WEBMCP_PROFILE_OPTIONS: WebMcpProfile[] = ['minimal', 'default', 'interactive', 'full']; + +export interface WebMcpGroupSummary { + name: string; + description: string; + defaultLoaded: boolean; + toolCount: number; +} + @Injectable() export class MCPConfigService extends Disposable { @Autowired(SumiMCPServerProxyServicePath) @@ -52,6 +71,9 @@ export class MCPConfigService extends Disposable { @Autowired(ILogger) private readonly logger: ILogger; + @Autowired(WebMcpGroupRegistryToken) + private readonly webMcpGroupRegistry: WebMcpGroupRegistry; + private chatStorage: IStorage; private mcpConfigStorage: IStorage; private whenReadyDeferred = new Deferred(); @@ -116,7 +138,14 @@ export class MCPConfigService extends Disposable { if (scope === PreferenceScope.Default) { const runningServers = await this.mcpServerProxyService.$getServers(); const builtinServer = runningServers.find((server) => server.name === BUILTIN_MCP_SERVER_NAME); - return builtinServer ? [builtinServer] : []; + return builtinServer + ? [ + { + ...builtinServer, + isStarted: await this.isBuiltinMCPEnabled(), + }, + ] + : []; } const userServers = Object.keys(mcpConfig!.mcpServers).map((name) => { @@ -155,13 +184,21 @@ export class MCPConfigService extends Disposable { // Add built-in server at the beginning if it exists if (builtinServer) { - allServers.unshift(builtinServer); + allServers.unshift({ + ...builtinServer, + isStarted: await this.isBuiltinMCPEnabled(), + }); } return allServers; } async controlServer(serverName: string, start: boolean): Promise { + if (serverName === BUILTIN_MCP_SERVER_NAME) { + await this.setBuiltinMCPEnabled(start); + return; + } + try { if (start) { await this.mcpServerProxyService.$startServer(serverName); @@ -188,6 +225,62 @@ export class MCPConfigService extends Disposable { } } + async setBuiltinMCPEnabled(enabled: boolean): Promise { + await this.whenReady; + try { + if (enabled) { + await this.mcpServerProxyService.$startServer(BUILTIN_MCP_SERVER_NAME); + } else { + await this.mcpServerProxyService.$stopServer(BUILTIN_MCP_SERVER_NAME); + } + + const disabledMCPServers = this.chatStorage.get(MCPServersDisabledKey, []); + const disabledMCPServersSet = new Set(disabledMCPServers); + if (enabled) { + disabledMCPServersSet.delete(BUILTIN_MCP_SERVER_NAME); + } else { + disabledMCPServersSet.add(BUILTIN_MCP_SERVER_NAME); + } + this.chatStorage.set(MCPServersDisabledKey, Array.from(disabledMCPServersSet)); + await this.preferenceService.set(AINativeSettingSectionsId.WebMcpEnabled, enabled); + this.fireMCPServersChange(); + } catch (error) { + const msg = error.message || error; + this.logger.error(`Failed to ${enabled ? 'start' : 'stop'} built-in MCP servers:`, msg); + this.messageService.error(msg); + throw error; + } + } + + async isBuiltinMCPEnabled(): Promise { + await this.whenReady; + const disabledMCPServers = this.chatStorage.get(MCPServersDisabledKey, []); + const webMcpEnabled = this.preferenceService.get(AINativeSettingSectionsId.WebMcpEnabled, true); + return !disabledMCPServers.includes(BUILTIN_MCP_SERVER_NAME) && webMcpEnabled !== false; + } + + getWebMcpProfile(): WebMcpProfile { + const profile = this.preferenceService.get(AINativeSettingSectionsId.WebMcpProfile, 'default'); + return WEBMCP_PROFILE_OPTIONS.includes(profile) ? profile : 'default'; + } + + async setWebMcpProfile(profile: WebMcpProfile): Promise { + if (!WEBMCP_PROFILE_OPTIONS.includes(profile)) { + return; + } + await this.preferenceService.set(AINativeSettingSectionsId.WebMcpProfile, profile); + this.fireMCPServersChange(); + } + + getWebMcpGroups(): WebMcpGroupSummary[] { + return this.webMcpGroupRegistry.getGroupDefinitions().map((group: WebMcpGroupDef) => ({ + name: group.name, + description: group.description, + defaultLoaded: group.defaultLoaded, + toolCount: group.tools.length, + })); + } + async saveServer(prev: MCPServerDescription | undefined, data: MCPServerFormData): Promise { await this.whenReady; const { value: mcpConfig } = this.preferenceService.resolve<{ mcpServers: Record }>( @@ -275,6 +368,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..f35be1fe4e 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'; @@ -9,6 +7,40 @@ import { ImageCompressionOptions, compressToolResultSmart } from '../../common/i import { IMCPServerProxyService, IMCPToolResult } from '../../common/types'; import { IMCPServerRegistry, TokenMCPServerRegistry } from '../types'; +function getJsonSchemaSourceSchema(inputSchema: any): any { + const def = inputSchema?._def ?? inputSchema?.def; + if (def?.type === 'pipe' && def.in) { + return getJsonSchemaSourceSchema(def.in); + } + if (def?.typeName === 'ZodEffects' && def.schema) { + return getJsonSchemaSourceSchema(def.schema); + } + return inputSchema; +} + +function toJSONSchema(inputSchema: any): any { + const sourceSchema = getJsonSchemaSourceSchema(inputSchema); + if (typeof sourceSchema?.toJSONSchema === 'function') { + return sourceSchema.toJSONSchema(); + } + return sourceSchema; +} + +function summarizeMCPTools(tools: Array<{ name: string; inputSchema: any }>) { + const toolStats = tools.map((tool) => { + const schemaBytes = Buffer.byteLength(JSON.stringify(tool.inputSchema ?? null), 'utf8'); + return { + name: tool.name, + schemaBytes, + }; + }); + return { + toolCount: tools.length, + schemaBytes: toolStats.reduce((total, tool) => total + tool.schemaBytes, 0), + largestSchemas: [...toolStats].sort((a, b) => b.schemaBytes - a.schemaBytes).slice(0, 5), + }; +} + @Injectable() export class MCPServerProxyService implements IMCPServerProxyService { @Autowired(TokenMCPServerRegistry) @@ -30,17 +62,18 @@ 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) => { + const jsonSchema = 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); + this.logger.log('SUMI MCP tools', summarizeMCPTools(tools)); return tools; } diff --git a/packages/ai-native/src/browser/preferences/schema.ts b/packages/ai-native/src/browser/preferences/schema.ts index 69f794fea6..9ff41e25fa 100644 --- a/packages/ai-native/src/browser/preferences/schema.ts +++ b/packages/ai-native/src/browser/preferences/schema.ts @@ -1,4 +1,4 @@ -import { AINativeSettingSectionsId, PreferenceSchema } from '@opensumi/ide-core-browser'; +import { AINativeSettingSectionsId, DEFAULT_ACP_THREAD_POOL_SIZE, PreferenceSchema } from '@opensumi/ide-core-browser'; import { CodeEditsRenderType } from '../contrib/intelligent-completions'; @@ -13,6 +13,20 @@ export enum ETerminalAutoExecutionPolicy { always = 'always', } +export enum EWebMcpProfile { + minimal = 'minimal', + default = 'default', + interactive = 'interactive', + full = 'full', +} + +export enum EAIPanelLayout { + classic = 'classic', + agentic = 'agentic', +} + +export const WEBMCP_PROFILE_SETTING_ID = 'ai.native.webmcp.profile'; + export const aiNativePreferenceSchema: PreferenceSchema = { properties: { [AINativeSettingSectionsId.InlineDiffPreviewMode]: { @@ -41,6 +55,12 @@ export const aiNativePreferenceSchema: PreferenceSchema = { enum: ['never', 'always', 'default'], default: 'default', }, + [AINativeSettingSectionsId.PanelLayout]: { + type: 'string', + enum: [EAIPanelLayout.classic, EAIPanelLayout.agentic], + default: EAIPanelLayout.classic, + description: 'Controls the AI Native panel layout.', + }, [AINativeSettingSectionsId.IntelligentCompletionsPromptEngineeringEnabled]: { type: 'boolean', default: true, @@ -206,6 +226,17 @@ export const aiNativePreferenceSchema: PreferenceSchema = { default: ETerminalAutoExecutionPolicy.auto, markdownDescription: '%ai.native.terminal.autorun.description%', }, + [AINativeSettingSectionsId.WebMcpEnabled]: { + type: 'boolean', + default: true, + description: 'Controls whether OpenSumi built-in WebMCP IDE capabilities are exposed to ACP agents.', + }, + [WEBMCP_PROFILE_SETTING_ID]: { + type: 'string', + enum: [EWebMcpProfile.minimal, EWebMcpProfile.default, EWebMcpProfile.interactive, EWebMcpProfile.full], + default: EWebMcpProfile.default, + description: 'Controls which OpenSumi WebMCP tools are exposed to ACP agents.', + }, [AINativeSettingSectionsId.CodeEditsTyping]: { type: 'boolean', default: false, @@ -219,5 +250,62 @@ export const aiNativePreferenceSchema: PreferenceSchema = { default: '', description: '%preference.ai.native.globalRules.description%', }, + [AINativeSettingSectionsId.NodePath]: { + type: 'string', + default: '', + description: '%preference.ai-native.acp.nodePath.description%', + }, + [AINativeSettingSectionsId.AcpThreadPoolSize]: { + type: 'number', + default: DEFAULT_ACP_THREAD_POOL_SIZE, + minimum: 1, + description: '%preference.ai-native.acp.threadPoolSize.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: {}, + }, + defaultModel: { + type: 'string', + description: 'Default ACP model id to apply when creating or loading a session.', + }, + defaultMode: { + type: 'string', + description: 'Default ACP mode id to apply when creating or loading a session.', + }, + defaultConfigOptions: { + type: 'object', + additionalProperties: { + anyOf: [{ type: 'string' }, { type: 'boolean' }], + }, + description: 'Default ACP session config option values keyed by config option id.', + default: {}, + }, + }, + }, + }, }, }; diff --git a/packages/ai-native/src/common/prompts/context-prompt-provider.ts b/packages/ai-native/src/common/prompts/context-prompt-provider.ts index dfc6c163ab..215de74729 100644 --- a/packages/ai-native/src/common/prompts/context-prompt-provider.ts +++ b/packages/ai-native/src/common/prompts/context-prompt-provider.ts @@ -1,4 +1,5 @@ import { Autowired, Injectable } from '@opensumi/di'; +import { ILogger } from '@opensumi/ide-core-common'; import { WorkbenchEditorService } from '@opensumi/ide-editor/lib/common/editor'; import { IWorkspaceService } from '@opensumi/ide-workspace'; @@ -22,13 +23,25 @@ export class DefaultChatAgentPromptProvider implements ChatAgentPromptProvider { @Autowired(IWorkspaceService) protected readonly workspaceService: IWorkspaceService; + @Autowired(ILogger) + protected readonly logger: ILogger; + async provideContextPrompt(context: SerializedContext, userMessage: string) { + const startTime = Date.now(); + this.logger.log( + `[ChatAgentPromptProvider] provideContextPrompt start — userMessageChars=${userMessage.length}, attachedFiles=${context.attachedFiles.length}, attachedFolders=${context.attachedFolders.length}, attachedRules=${context.attachedRules.length}, globalRules=${context.globalRules.length}`, + ); let currentFileInfo = await this.getCurrentFileInfo(); + this.logger.log( + `[ChatAgentPromptProvider] current file resolved — hasCurrentFile=${Boolean(currentFileInfo)}, elapsedMs=${ + Date.now() - startTime + }`, + ); if (context.attachedFiles.some((file) => file.path === currentFileInfo?.path)) { currentFileInfo = null; } - return this.buildPromptTemplate({ + const prompt = await this.buildPromptTemplate({ attachedFiles: context.attachedFiles, attachedFolders: context.attachedFolders, currentFile: currentFileInfo, @@ -36,18 +49,31 @@ export class DefaultChatAgentPromptProvider implements ChatAgentPromptProvider { globalRules: context.globalRules, userMessage, }); + this.logger.log( + `[ChatAgentPromptProvider] provideContextPrompt done — promptChars=${prompt.length}, elapsedMs=${ + Date.now() - startTime + }`, + ); + return prompt; } private async getCurrentFileInfo() { + const startTime = Date.now(); const editor = this.workbenchEditorService.currentEditor; const currentModel = editor?.currentDocumentModel; if (!currentModel?.uri) { + this.logger.log('[ChatAgentPromptProvider] getCurrentFileInfo skipped — no current model'); return null; } const currentPath = (await this.workspaceService.asRelativePath(currentModel.uri))?.path || currentModel.uri.codeUri.fsPath; + this.logger.log( + `[ChatAgentPromptProvider] getCurrentFileInfo path resolved — path=${currentPath}, elapsedMs=${ + Date.now() - startTime + }`, + ); // 获取当前选中行信息 const selection = editor?.monacoEditor?.getSelection(); 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/common/webmcp-policy.ts b/packages/ai-native/src/common/webmcp-policy.ts new file mode 100644 index 0000000000..99874c0c9c --- /dev/null +++ b/packages/ai-native/src/common/webmcp-policy.ts @@ -0,0 +1,36 @@ +export type WebMcpToolRiskLevel = 'read' | 'write' | 'destructive' | 'shell' | 'ui'; +export type WebMcpProfile = 'minimal' | 'default' | 'interactive' | 'full'; + +export interface WebMcpToolPolicyMetadata { + riskLevel?: WebMcpToolRiskLevel; + exposedByDefault?: boolean; + profiles?: WebMcpProfile[]; +} + +export function isValidWebMcpProfile(profile: unknown): profile is WebMcpProfile { + return profile === 'minimal' || profile === 'default' || profile === 'interactive' || profile === 'full'; +} + +export function isWebMcpToolInProfile(tool: WebMcpToolPolicyMetadata, profile: WebMcpProfile): boolean { + if (tool.profiles?.length) { + return tool.profiles.includes(profile); + } + if (profile === 'full') { + return true; + } + if (tool.riskLevel === 'shell') { + return profile === 'interactive'; + } + if (tool.riskLevel === 'destructive' || tool.riskLevel === 'write') { + return false; + } + return profile === 'minimal' ? tool.riskLevel === 'read' : tool.riskLevel === 'read' || tool.riskLevel === 'ui'; +} + +export function isWebMcpToolExposedByDefault(tool: WebMcpToolPolicyMetadata): boolean { + return tool.exposedByDefault !== false; +} + +export function canExposeWebMcpTool(tool: WebMcpToolPolicyMetadata, profile: WebMcpProfile): boolean { + return isWebMcpToolExposedByDefault(tool) && isWebMcpToolInProfile(tool, profile); +} diff --git a/packages/ai-native/src/node/acp/acp-agent-update-adapter.ts b/packages/ai-native/src/node/acp/acp-agent-update-adapter.ts new file mode 100644 index 0000000000..c10c411dd3 --- /dev/null +++ b/packages/ai-native/src/node/acp/acp-agent-update-adapter.ts @@ -0,0 +1,141 @@ +import { SessionNotification } from '@opensumi/ide-core-common/lib/types/ai-native/acp-types'; + +import type { AgentUpdate } from './acp-update-types'; + +interface PlanEntryLike { content: string; completed?: boolean; status?: string } + +function getPlanEntries(update: any): PlanEntryLike[] | undefined { + const entries = update.plan?.entries ?? update.entries; + return Array.isArray(entries) ? entries : undefined; +} + +/** + * Translate a native ACP SessionNotification into the legacy AgentUpdate format + * for stream consumers that have not migrated to ACP-native updates yet. + */ +export function toAgentUpdate(notification: SessionNotification): AgentUpdate | 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 !== undefined ? update.rawInput : {}, + status: 'pending' as const, + }, + }; + } + + case 'tool_call_update': { + const updates: AgentUpdate[] = []; + if (update.rawInput !== undefined) { + updates.push({ + type: 'tool_call_args', + content: '', + toolCall: { + toolCallId: update.toolCallId || '', + name: update.title || '', + input: update.rawInput, + }, + }); + } + if (update.status === 'completed' || update.status === 'failed') { + if (update.rawOutput != null) { + const outputText = typeof update.rawOutput === 'string' ? update.rawOutput : JSON.stringify(update.rawOutput); + updates.push({ + type: 'tool_result', + content: outputText.slice(0, 2000), + toolCall: { + toolCallId: update.toolCallId || '', + name: '', + status: update.status as 'completed' | 'failed', + }, + }); + } + return updates.length ? updates : null; + } + if (update.status === 'in_progress') { + updates.push({ + type: 'tool_call_status', + content: update.title || '', + toolCall: { + toolCallId: update.toolCallId || '', + name: update.title || '', + status: 'in_progress' as const, + }, + }); + return updates; + } + if (update.content) { + for (const item of update.content) { + if (item.type === 'diff') { + updates.push({ + type: 'tool_result', + content: `Modified ${item.path}`, + }); + break; + } + } + } + return updates.length ? updates : null; + } + + case 'plan': { + const entries = getPlanEntries(update); + if (entries?.length) { + const planText = entries + .map((e) => (e.completed || e.status === 'completed' ? `- [x] ${e.content}` : `- [ ] ${e.content}`)) + .join('\n'); + return { type: 'plan', content: `${planText}\n\n` }; + } + return null; + } + + case 'current_mode_update': { + return { + type: 'session_state', + content: '', + sessionId: (notification as any).sessionId, + currentModeId: update.currentModeId, + }; + } + + case 'config_option_update': { + if (!Array.isArray(update.configOptions)) { + return null; + } + return { + type: 'session_state', + content: '', + sessionId: (notification as any).sessionId, + configOptions: update.configOptions, + }; + } + + default: + return null; + } +} 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..e094cd2a67 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,37 @@ import { Autowired, Injectable } from '@opensumi/di'; +import { Deferred, Disposable, Emitter, Event, IDisposable } from '@opensumi/ide-core-common'; +import { DEFAULT_ACP_THREAD_POOL_SIZE } from '@opensumi/ide-core-common/lib/settings/ai-native'; 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, + AcpDebugLogEntry, + AvailableCommand, + ListSessionsRequest, + ListSessionsResponse, + McpServer, + OpenSumiMcpServerConnectionInfo, + 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 { toAgentUpdate } from './acp-agent-update-adapter'; +import { acpDebugLogStore } from './acp-debug-log'; +import { getAcpErrorMessage, normalizeAcpError } from './acp-error'; +import { + AcpThread, + AcpThreadEvent, + AcpThreadFactory, + AcpThreadFactoryToken, + AcpThreadRuntimeConfig, + ThreadStatus, +} from './acp-thread'; import { AcpTerminalHandler, AcpTerminalHandlerToken } from './handlers/terminal.handler'; +import { OpenSumiMcpHttpServer } from './opensumi-mcp-http-server'; +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 @@ -38,6 +39,12 @@ export interface SessionLoadResult { export const AcpAgentServiceToken = Symbol('AcpAgentServiceToken'); +const WEBMCP_CAPABILITY_HINT = [ + '', + 'Use the opensumi-ide MCP catalog tools to discover and enable IDE capability groups before invoking non-default OpenSumi tools.', + '', +].join('\n'); + // ============================================================================ // Agent Session Types // ============================================================================ @@ -51,541 +58,1725 @@ 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; description?: string }>; + currentModeId?: string; + models?: Array<{ modelId: string; name: string; description?: string | null }>; + currentModelId?: string; + configOptions?: Record[]; + status: AgentSessionStatus; + historyUpdates: SessionNotification[]; +} + +interface PendingSessionLoad { + promise: Promise; + refCount: number; + thread: AcpThread; + closeRequested: boolean; +} + +type AgentThreadRuntimeConfigKey = Pick< + AgentProcessConfig, + 'agentId' | 'command' | 'args' | 'cwd' | 'env' | 'nodePath' +>; + +// ============================================================================ +// 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(sessionId?: string): AgentSessionInfo | null; + + /** + * Create a new session */ - getSessionInfo(): AgentSessionInfo | null; + createSession(config: AgentProcessConfig): Promise<{ + sessionId: string; + availableCommands: AvailableCommand[]; + modes?: Array<{ id: string; name: string; description?: string }>; + currentModeId?: string; + models?: Array<{ modelId: string; name: string; description?: string | null }>; + currentModelId?: string; + configOptions?: Record[]; + }>; + + /** + * List all ACP Agent sessions + */ + listSessions(params?: ListSessionsRequest, config?: AgentProcessConfig): Promise; + + /** + * 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; - createSession(config: AgentProcessConfig): Promise<{ sessionId: string; availableCommands: AvailableCommand[] }>; + /** Switch the AI model for the session */ + setSessionModel(params: { sessionId: string; model: string }): Promise; /** - * 列出所有 ACP Agent 会话 + * 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. */ - listSessions(params?: ListSessionsRequest): Promise; + disposeSession(sessionId: string, force?: boolean): Promise; /** - * 切换 Session 模式 + * Get available modes from initialize negotiation */ - setSessionMode(params: SetSessionModeRequest): Promise; + getAvailableModes(): Promise; + + getAcpDebugLog(): Promise; + + clearAcpDebugLog(): Promise; /** - * 释放指定 Session 的资源(包括终端等) + * Start and return the loopback HTTP MCP server connection for external MCP clients. */ - disposeSession(sessionId: string): Promise; + getOpenSumiMcpServerConnection(clientId?: string): 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; + @Autowired(OpenSumiMcpHttpServer) + private readonly opensumiMcpHttpServer: OpenSumiMcpHttpServer | undefined; - // 全局 Agent 进程 ID(单一实例) - private currentProcessId: string | null = null; + // Session -> Thread mapping (active sessions) + private sessions = new Map(); - // 当前活跃的通知处理器和 stream - private currentNotificationHandler: { - unsubscribe: () => void; - stream: SumiReadableStream; - sessionId: string; - } | null = null; + // Session -> in-flight load task. Prevents concurrent loadSession calls + // from observing a pre-registered but not-yet-loaded thread. + private pendingSessionLoads = new Map(); - // 确保初始化只执行一次 - private initializingPromise: Promise | null = null; + // Session -> number of UI/callers currently holding this loaded session. + private sessionRefCounts = new Map(); - // 断开事件订阅的取消函数 - private disconnectUnsubscribe: (() => void) | null = null; + // Sessions that actually received the built-in opensumi-ide MCP server. + private builtInMcpSessionIds = new Set(); - 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); - } - }; + // Thread -> agent process config key. Idle threads can only be reused when + // they were created for the same agent process command and environment. + private threadRuntimeConfigKeys = new WeakMap(); - // 订阅临时通知处理器 - const unsubscribe = this.clientService.onNotification(tempHandler); + // Threads reserved by createSession() before the real ACP sessionId is known. + private reservedThreads = new Set(); - try { - const res = await Promise.race([ - this.clientService.newSession({ cwd: config.workspaceDir, mcpServers: [] }), - new Promise((_, reject) => setTimeout(() => reject(new Error('Create session timeout')), 60000)), - ]); + // Pool limit (configurable) + private maxPoolSize = DEFAULT_ACP_THREAD_POOL_SIZE; - // 等待延迟的 session/update 通知,增加等待时间以确保 availableCommands 通知到达 - await new Promise((resolve) => setTimeout(resolve, 2000)); + // Cached session info for backward compat (getSessionInfo without sessionId) + private lastSessionInfo: AgentSessionInfo | null = null; - // 根据 name 去重 - const seen = new Set(); - const deduplicated = availableCommands.filter((cmd) => { - if (seen.has(cmd.name)) { - return false; - } - seen.add(cmd.name); - return true; - }); + // Persistent thread status change listeners (survives across sendMessage streams) + private threadStatusDisposables = new Map(); + + private _onThreadStatusChange = new Emitter<{ sessionId: string; status: ThreadStatus }>(); + readonly onThreadStatusChange: Event<{ sessionId: string; status: ThreadStatus }> = this._onThreadStatusChange.event; + + // ----------------------------------------------------------------------- + // 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 { + this.syncMaxPoolSize(config); + + // 1. Active session mapping exists + const existing = this.sessions.get(sessionId); + if (existing && existing.getStatus() !== 'disconnected') { + this.touchSession(sessionId); + 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.reservedThreads.has(t) && + !this.hasActiveSession(t) && + this.canReuseThreadForConfig(t, config) && + ['idle', 'awaiting_prompt'].includes(t.getStatus()), ); + if (idleThread) { + this.bindSession(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.bindSession(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 — replace the least recently used reusable thread. + const recycledThread = await this.recycleLeastRecentlyUsedThread(sessionId, 'load-or-new', config); + this.bindSession(sessionId, recycledThread); + return recycledThread; } /** - * 获取当前 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; + private bindSession(sessionId: string, thread: AcpThread): void { + this.sessions.delete(sessionId); + this.sessions.set(sessionId, thread); + } + + private touchSession(sessionId: string): void { + const thread = this.sessions.get(sessionId); + if (!thread) { + return; } + this.bindSession(sessionId, thread); + } - if (this.initializingPromise) { - return this.initializingPromise; + private syncMaxPoolSize(config: AgentProcessConfig): void { + const { threadPoolSize } = config; + if (typeof threadPoolSize !== 'number' || !Number.isFinite(threadPoolSize) || threadPoolSize < 1) { + this.maxPoolSize = DEFAULT_ACP_THREAD_POOL_SIZE; + return; } + this.maxPoolSize = Math.floor(threadPoolSize); + } - this.initializingPromise = (async () => { - const processId = await this.ensureConnected(config); + private isThreadReusableForLRU(thread: AcpThread): boolean { + return ['idle', 'awaiting_prompt'].includes(thread.getStatus()); + } - const newSessionRequest: NewSessionRequest = { - cwd: config.workspaceDir, - mcpServers: [], - }; + private createThreadRuntimeConfigKey(config: AgentThreadRuntimeConfigKey): string { + const env = Array.isArray(config.env) + ? config.env + .map((item) => ({ + name: String((item as { name?: unknown }).name ?? ''), + value: String((item as { value?: unknown }).value ?? ''), + })) + .sort((a, b) => a.name.localeCompare(b.name) || a.value.localeCompare(b.value)) + : []; + + return JSON.stringify({ + agentId: config.agentId, + command: config.command, + args: config.args ?? [], + cwd: config.cwd, + env, + nodePath: config.nodePath ?? '', + }); + } - const newSessionResponse = await this.clientService.newSession(newSessionRequest); + private rememberThreadRuntimeConfig(thread: AcpThread, config: AgentThreadRuntimeConfigKey): void { + this.threadRuntimeConfigKeys.set(thread, this.createThreadRuntimeConfigKey(config)); + } - this.sessionInfo = { - sessionId: newSessionResponse.sessionId, - processId, - modes: (newSessionResponse.modes?.availableModes ?? []) as SessionMode[], - status: 'ready', + private canReuseThreadForConfig(thread: AcpThread, config: AgentProcessConfig): boolean { + return this.threadRuntimeConfigKeys.get(thread) === this.createThreadRuntimeConfigKey(config); + } + + private getBoundSessionId(thread: AcpThread): string | undefined { + for (const [sessionId, mappedThread] of this.sessions) { + if (mappedThread === thread) { + return sessionId; + } + } + return undefined; + } + + private async disposeLeastRecentlyUsedReusableThread(nextSessionId: string, reason: string): Promise { + for (const thread of this.threadPool) { + const sessionId = this.getBoundSessionId(thread); + if ( + this.reservedThreads.has(thread) || + (sessionId ? this.pendingSessionLoads.has(sessionId) : false) || + !this.isThreadReusableForLRU(thread) + ) { + continue; + } + + this.reservedThreads.add(thread); + this.logger.log( + `[AcpAgentService] thread-pool-dispose — reason=${reason}, evictSessionId=${ + sessionId ?? '-' + }, nextSessionId=${nextSessionId}, threadId=${thread.threadId}, status=${thread.getStatus()}, pool=${ + this.threadPool.length + }/${this.maxPoolSize}`, + ); + try { + if (sessionId) { + await this.terminalHandler.releaseSessionTerminals(sessionId); + this.permissionRouting.unregisterSession(sessionId); + this.unregisterThreadStatusListener(sessionId); + this.sessions.delete(sessionId); + this.sessionRefCounts.delete(sessionId); + this.builtInMcpSessionIds.delete(sessionId); + } + await thread.dispose(); + const idx = this.threadPool.indexOf(thread); + if (idx !== -1) { + this.threadPool.splice(idx, 1); + } + this.reservedThreads.delete(thread); + return; + } catch (error) { + this.reservedThreads.delete(thread); + throw error; + } + } + + const candidates = this.threadPool.map((thread) => { + const sessionId = this.getBoundSessionId(thread); + const status = thread.getStatus(); + return { + threadId: thread.threadId, + sessionId: sessionId ?? '-', + status, + reserved: this.reservedThreads.has(thread), + pendingLoad: sessionId ? this.pendingSessionLoads.has(sessionId) : false, + reusable: ['idle', 'awaiting_prompt'].includes(status), }; + }); + this.logger.warn( + `[AcpAgentService] thread-pool-dispose-failed — reason=${reason}, nextSessionId=${nextSessionId}, pool=${ + this.threadPool.length + }/${this.maxPoolSize}, candidates=${JSON.stringify(candidates)}`, + ); + throw new Error(`Thread pool is full (${this.maxPoolSize}), no reusable LRU thread available`); + } - this.currentProcessId = processId; + private async recycleLeastRecentlyUsedThread( + nextSessionId: string, + reason: string, + config?: AgentProcessConfig, + ): Promise { + for (const [sessionId, thread] of this.sessions) { + if ( + this.reservedThreads.has(thread) || + this.pendingSessionLoads.has(sessionId) || + !this.isThreadReusableForLRU(thread) || + (config && !this.canReuseThreadForConfig(thread, config)) + ) { + continue; + } - return this.sessionInfo; - })(); + this.reservedThreads.add(thread); + this.logger.log( + `[AcpAgentService] thread-pool-switch — reason=${reason}, evictSessionId=${sessionId}, nextSessionId=${nextSessionId}, threadId=${ + thread.threadId + }, status=${thread.getStatus()}, pool=${this.threadPool.length}/${this.maxPoolSize}`, + ); + try { + await this.terminalHandler.releaseSessionTerminals(sessionId); + this.permissionRouting.unregisterSession(sessionId); + this.unregisterThreadStatusListener(sessionId); + this.sessions.delete(sessionId); + this.sessionRefCounts.delete(sessionId); + this.builtInMcpSessionIds.delete(sessionId); + return thread; + } catch (error) { + this.reservedThreads.delete(thread); + throw error; + } + } - try { - const result = await this.initializingPromise; - return result; - } finally { - this.initializingPromise = null; + if (config) { + await this.disposeLeastRecentlyUsedReusableThread(nextSessionId, `${reason}-different-config`); + const thread = this.createThreadInstance(nextSessionId === 'pending-create-session' ? '' : nextSessionId, config); + this.threadPool.push(thread); + return thread; } + + const candidates = this.threadPool.map((thread) => { + const sessionId = this.getBoundSessionId(thread); + const status = thread.getStatus(); + return { + threadId: thread.threadId, + sessionId: sessionId ?? '-', + status, + reserved: this.reservedThreads.has(thread), + pendingLoad: sessionId ? this.pendingSessionLoads.has(sessionId) : false, + reusable: ['idle', 'awaiting_prompt'].includes(status), + }; + }); + this.logger.warn( + `[AcpAgentService] thread-pool-switch-failed — reason=${reason}, nextSessionId=${nextSessionId}, pool=${ + this.threadPool.length + }/${this.maxPoolSize}, candidates=${JSON.stringify(candidates)}`, + ); + throw new Error(`Thread pool is full (${this.maxPoolSize}), no reusable LRU thread available`); } /** - * 加载已有 Agent Session + * Create a new AcpThread instance via factory. */ - async loadSession(sessionId: string, config: AgentProcessConfig): Promise { - const processId = await this.ensureConnected(config); + 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.rememberThreadRuntimeConfig(thread, 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 { + this.syncMaxPoolSize(config); + + const idleThread = this.threadPool.find( + (t) => + !this.reservedThreads.has(t) && + !this.hasActiveSession(t) && + this.canReuseThreadForConfig(t, config) && + ['idle', 'awaiting_prompt'].includes(t.getStatus()), + ); + if (idleThread) { + this.reservedThreads.add(idleThread); + return idleThread; + } + + 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.rememberThreadRuntimeConfig(thread, runtimeConfig); + this.threadPool.push(thread); + this.reservedThreads.add(thread); + return thread; + } + + const recycledThread = await this.recycleLeastRecentlyUsedThread( + 'pending-create-session', + 'create-session', + config, + ); + this.reservedThreads.add(recycledThread); + return recycledThread; + } - const historyUpdates: SessionNotification[] = []; + private async getSessionMcpServers(thread: AcpThread, config: AgentProcessConfig): Promise { + const mcpServers = config.mcpServers ?? []; - // 设置临时通知处理器来收集 session/update - const tempHandler = (notification: SessionNotification) => { - if (notification.sessionId === sessionId && notification.update) { - historyUpdates.push(notification); + const mcpCapabilities = thread.agentCapabilities?.mcpCapabilities; + const configuredServers = 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; + }); - // 订阅临时通知处理器 - const unsubscribe = this.clientService.onNotification(tempHandler); + if (config.webMcp?.enabled === false) { + this.logger.log('[AcpAgentService] Skipping built-in MCP server; WebMCP is disabled by configuration'); + return configuredServers; + } - const loadRequest: LoadSessionRequest = { - sessionId, - cwd: config.workspaceDir, - mcpServers: [], - }; + if (mcpCapabilities?.http !== true || !this.opensumiMcpHttpServer) { + return configuredServers; + } + + const serverName = this.opensumiMcpHttpServer.getServerName(); + if (configuredServers.some((server) => server.name === serverName)) { + this.logger.warn(`[AcpAgentService] Skipping built-in MCP server "${serverName}"; name already configured`); + return configuredServers; + } try { - await Promise.race([ - this.clientService.loadSession(loadRequest), - new Promise((_, reject) => - setTimeout(() => reject(new Error(`Session load timeout for ${sessionId}`)), 60000), - ), - ]); - - // 等待延迟的 session/update 通知 - await new Promise((resolve) => setTimeout(resolve, 500)); - } finally { - unsubscribe(); + await this.opensumiMcpHttpServer.start(); + return [ + ...configuredServers, + { + name: serverName, + type: 'http', + url: this.opensumiMcpHttpServer.getUrl(), + headers: [], + }, + ]; + } catch (error) { + this.logger.warn(`[AcpAgentService] Skipping built-in MCP server "${serverName}"; failed to start`, error); + return configuredServers; } + } + + private didAppendBuiltInMcpServer(config: AgentProcessConfig, mcpServers: McpServer[]): boolean { + const serverName = this.opensumiMcpHttpServer?.getServerName(); + if (!serverName || (config.mcpServers ?? []).some((server) => server.name === serverName)) { + return false; + } + return mcpServers.some((server) => server.name === serverName); + } + + private setBuiltInMcpSessionState(sessionId: string, enabled: boolean): void { + if (enabled) { + this.builtInMcpSessionIds.add(sessionId); + } else { + this.builtInMcpSessionIds.delete(sessionId); + } + } + + async getOpenSumiMcpServerConnection(clientId?: string): Promise { + if (!this.opensumiMcpHttpServer) { + throw new Error('[AcpAgentService] OpenSumi MCP server is not available'); + } + await this.opensumiMcpHttpServer.start(); + return this.opensumiMcpHttpServer.getConnectionInfo(clientId); + } - 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 }); + // ----------------------------------------------------------------------- + // createSession — with Deferred pattern (NOT setTimeout) + // ----------------------------------------------------------------------- + + async createSession(config: AgentProcessConfig): Promise<{ + sessionId: string; + availableCommands: AvailableCommand[]; + modes?: Array<{ id: string; name: string; description?: string }>; + currentModeId?: string; + models?: Array<{ modelId: string; name: string; description?: string | null }>; + currentModelId?: string; + configOptions?: Record[]; + }> { + 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; + + 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 { + if (!thread.initialized) { + await thread.initialize(config as any); + } + if (thread.needsReset) { + thread.reset(); + } + + const mcpServers = await this.getSessionMcpServers(thread, config); + const newSessionResponse = await thread.newSession({ + cwd: config.cwd, + mcpServers, + } as any); + + realSessionId = newSessionResponse.sessionId; + this.setBuiltInMcpSessionState(realSessionId, this.didAppendBuiltInMcpServer(config, mcpServers)); + await this.applyDefaultSessionOptions(realSessionId, thread, config); + this.bindSession(realSessionId, thread); + this.sessionRefCounts.set(realSessionId, 1); + this.permissionRouting.registerSession(realSessionId); + this.registerThreadStatusListener(realSessionId, thread); + + await Promise.race([deferred.promise, new Promise((resolve) => setTimeout(resolve, 5000))]); + + const seen = new Set(); + const deduplicated = availableCommands.filter((cmd) => { + if (seen.has(cmd.name)) { + return false; + } + seen.add(cmd.name); + return true; + }); + + const sessionState = thread.getSessionState(); + const modes = sessionState.modes + ? sessionState.modes.map(({ id, name, description }) => ({ id, name, description: description ?? undefined })) + : []; + this.updateLastSessionInfo(realSessionId, thread, modes); + + this.logger.log( + `[AcpAgentService] createSession() — done, sessionId=${realSessionId}, commands=${deduplicated.length}`, + ); + this.logPoolStatus('after-createSession'); + + return { + sessionId: realSessionId, + availableCommands: deduplicated, + modes, + currentModeId: sessionState.currentModeId, + models: sessionState.models ? [...sessionState.models] : undefined, + currentModelId: sessionState.currentModelId, + configOptions: sessionState.configOptions + ? ([...sessionState.configOptions] as Record[]) + : undefined, + }; + } catch (e) { + if (realSessionId) { + this.sessions.delete(realSessionId); + this.sessionRefCounts.delete(realSessionId); + this.builtInMcpSessionIds.delete(realSessionId); + this.permissionRouting.unregisterSession(realSessionId); + this.unregisterThreadStatusListener(realSessionId); + } + this.logger.error(`[AcpAgentService] createSession() — failed: ${getAcpErrorMessage(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.reservedThreads.delete(thread); + disposable.dispose(); } + } - this.sessionInfo = { - sessionId, - processId, - modes, + // ----------------------------------------------------------------------- + // initializeAgent — create a session and return info + // ----------------------------------------------------------------------- + + 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', }; + } + + // ----------------------------------------------------------------------- + // loadSession + // ----------------------------------------------------------------------- + + async loadSession(sessionId: string, config: AgentProcessConfig): Promise { + this.syncMaxPoolSize(config); + this.logger.log(`[AcpAgentService] loadSession() — sessionId=${sessionId}`); + + // 1. If a load for this session is already in flight, join it. The + // sessions map may already contain a pre-registered thread at this point, + // but that thread is not safe to expose until the load RPC completes. + const pendingLoad = this.pendingSessionLoads.get(sessionId); + if (pendingLoad) { + pendingLoad.refCount += 1; + this.logger.log( + `[AcpAgentService] loadSession() — joining pending load, sessionId=${sessionId}, refs=${pendingLoad.refCount}`, + ); + return pendingLoad.promise; + } + + // 2. sessions.get(sessionId) exists and no pending load -> already loaded + const existingThread = this.sessions.get(sessionId); + if (existingThread && existingThread.getStatus() !== 'disconnected') { + this.touchSession(sessionId); + this.retainSession(sessionId); + 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); + } + + // 3. Pool has idle Thread + const idleThread = this.threadPool.find( + (t) => + !this.reservedThreads.has(t) && + !this.hasActiveSession(t) && + this.canReuseThreadForConfig(t, config) && + ['idle', 'awaiting_prompt'].includes(t.getStatus()), + ); + if (idleThread) { + this.logger.log( + `[AcpAgentService] loadSession() — reusing idle thread ${idleThread.threadId}, cwd=${idleThread.cwd}`, + ); + this.reservedThreads.add(idleThread); + this.bindSession(sessionId, idleThread); + this.permissionRouting.registerSession(sessionId); + this.registerThreadStatusListener(sessionId, idleThread); + return this.startPendingLoadSessionAndReleaseReservation(sessionId, idleThread, config, false); + } + + // 4. 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.bindSession(sessionId, thread); + this.permissionRouting.registerSession(sessionId); + this.registerThreadStatusListener(sessionId, thread); + return this.startPendingLoadSession(sessionId, thread, config, true); + } + + // 5. Pool full, no idle -> recycle least recently used reusable Thread + const recycledThread = await this.recycleLeastRecentlyUsedThread(sessionId, 'load-session', config); + this.bindSession(sessionId, recycledThread); + this.permissionRouting.registerSession(sessionId); + this.registerThreadStatusListener(sessionId, recycledThread); + return this.startPendingLoadSessionAndReleaseReservation(sessionId, recycledThread, config, false); + } + + private startPendingLoadSessionAndReleaseReservation( + sessionId: string, + thread: AcpThread, + config: AgentProcessConfig, + shouldDisposeThreadOnFailure: boolean, + ): Promise { + const promise = this.startPendingLoadSession(sessionId, thread, config, shouldDisposeThreadOnFailure); + this.reservedThreads.delete(thread); + return promise; + } + + private startPendingLoadSession( + sessionId: string, + thread: AcpThread, + config: AgentProcessConfig, + shouldDisposeThreadOnFailure: boolean, + ): Promise { + const pending: PendingSessionLoad = { + promise: Promise.resolve(null as unknown as SessionLoadResult), + refCount: 1, + thread, + closeRequested: false, + }; - this.currentProcessId = processId; + const promise = this.doLoadSession(sessionId, thread, config) + .then(() => { + if (pending.closeRequested) { + throw new Error(`Session load was disposed before completion: ${sessionId}`); + } + this.sessionRefCounts.set(sessionId, pending.refCount); + return this.buildSessionLoadResult(sessionId, thread); + }) + .catch(async (e) => { + this.sessions.delete(sessionId); + this.sessionRefCounts.delete(sessionId); + this.builtInMcpSessionIds.delete(sessionId); + this.permissionRouting.unregisterSession(sessionId); + this.unregisterThreadStatusListener(sessionId); + if (shouldDisposeThreadOnFailure) { + const idx = this.threadPool.indexOf(thread); + if (idx !== -1) { + this.threadPool.splice(idx, 1); + } + await thread.dispose(); + } else { + thread.reset(); + } + this.logger.error(`[AcpAgentService] loadSession() — failed: ${getAcpErrorMessage(e)}`); + throw e; + }) + .finally(() => { + this.pendingSessionLoads.delete(sessionId); + }); + + pending.promise = promise; + this.pendingSessionLoads.set(sessionId, pending); + return promise; + } + + private async doLoadSession(sessionId: string, thread: AcpThread, config: AgentProcessConfig): Promise { + if (!thread.initialized) { + await thread.initialize(config as any); + } + if (thread.needsReset) { + thread.reset(); + } + const mcpServers = await this.getSessionMcpServers(thread, config); + await thread.loadSession({ + sessionId, + cwd: config.cwd, + mcpServers, + } as any); + this.setBuiltInMcpSessionState(sessionId, this.didAppendBuiltInMcpServer(config, mcpServers)); + await this.applyDefaultSessionOptions(sessionId, thread, config); + } - const result: SessionLoadResult = { + private buildSessionLoadResult(sessionId: string, thread: AcpThread): SessionLoadResult { + const historyUpdates = [...thread.getSessionNotifications()]; + const sessionState = thread.getSessionState(); + const modes = sessionState.modes + ? sessionState.modes.map(({ id, name, description }) => ({ id, name, description: description ?? undefined })) + : []; + + this.updateLastSessionInfo(sessionId, thread, modes); + + return { sessionId, - processId, + processId: thread.threadId, modes, + currentModeId: sessionState.currentModeId, + models: sessionState.models ? [...sessionState.models] : undefined, + currentModelId: sessionState.currentModelId, + configOptions: sessionState.configOptions + ? ([...sessionState.configOptions] as Record[]) + : undefined, status: 'ready', historyUpdates, }; - - return result; } - /** - * 发送消息到 Agent(无状态) - */ - sendMessage(request: AgentRequest): SumiReadableStream { - const stream = new SumiReadableStream(); + private async applyDefaultSessionOptions( + sessionId: string, + thread: AcpThread, + config: AgentProcessConfig, + ): Promise { + const sessionState = thread.getSessionState(); + + if (config.defaultMode) { + const hasMode = sessionState.modes?.some((mode) => mode.id === config.defaultMode) === true; + if (hasMode) { + try { + await thread.setSessionMode({ sessionId, modeId: config.defaultMode } as any); + } catch (error) { + this.logger.warn(`[AcpAgentService] Failed to apply defaultMode "${config.defaultMode}"`, error); + } + } else { + this.logger.warn(`[AcpAgentService] Invalid defaultMode "${config.defaultMode}" for session ${sessionId}`); + } + } + + if (config.defaultModel) { + const hasModel = sessionState.models?.some((model) => model.modelId === config.defaultModel) === true; + if (hasModel) { + try { + await thread.unstable_setSessionModel({ sessionId, model: config.defaultModel } as any); + } catch (error) { + this.logger.warn(`[AcpAgentService] Failed to apply defaultModel "${config.defaultModel}"`, error); + } + } else { + this.logger.warn(`[AcpAgentService] Invalid defaultModel "${config.defaultModel}" for session ${sessionId}`); + } + } + + const defaults = config.defaultConfigOptions; + if (!defaults || Object.keys(defaults).length === 0) { + return; + } + + const configOptions = Array.isArray(sessionState.configOptions) ? sessionState.configOptions : []; + for (const [configId, value] of Object.entries(defaults)) { + const option = configOptions.find((item) => this.getConfigOptionId(item) === configId); + if (!option) { + this.logger.warn(`[AcpAgentService] Invalid defaultConfigOptions key "${configId}" for session ${sessionId}`); + continue; + } + + if (typeof value === 'string') { + const validValues = this.collectConfigOptionValues(option); + if (validValues.size === 0 || !validValues.has(value)) { + this.logger.warn( + `[AcpAgentService] Invalid defaultConfigOptions value "${value}" for config option "${configId}"`, + ); + continue; + } + } - if (!this.currentProcessId) { - stream.emitError(new Error('Agent process not initialized')); - return stream; + try { + await thread.setSessionConfigOption({ sessionId, configId, value } as any); + } catch (error) { + this.logger.warn(`[AcpAgentService] Failed to apply defaultConfigOptions "${configId}"`, error); + } } + } - const promptBlocks = this.buildPromptBlocks(request.prompt, request.images); + private getConfigOptionId(option: unknown): string | undefined { + const rawId = (option as { id?: unknown; configId?: unknown })?.id ?? (option as { configId?: unknown })?.configId; + if (typeof rawId === 'string') { + return rawId; + } + if (rawId && typeof rawId === 'object' && typeof (rawId as { id?: unknown }).id === 'string') { + return (rawId as { id: string }).id; + } + return undefined; + } - const promptRequest = { - sessionId: request.sessionId, - prompt: promptBlocks, + private collectConfigOptionValues(option: unknown): Set { + const values = new Set(); + const roots = [ + (option as any)?.options, + (option as any)?.values, + (option as any)?.kind?.options, + (option as any)?.kind?.select?.options, + (option as any)?.select?.options, + ].filter(Boolean); + + const visit = (node: unknown): void => { + if (Array.isArray(node)) { + node.forEach(visit); + return; + } + if (!node || typeof node !== 'object') { + return; + } + const record = node as Record; + const value = record.value; + if (typeof value === 'string') { + values.add(value); + } else if (value && typeof value === 'object' && typeof (value as { id?: unknown }).id === 'string') { + values.add((value as { id: string }).id); + } + visit(record.options); + visit(record.values); + visit(record.groups); }; - const unsubscribe = this.clientService.onNotification((notification: SessionNotification) => { - if (notification.sessionId !== request.sessionId) { + roots.forEach(visit); + return values; + } + + // ----------------------------------------------------------------------- + // sendMessage — streaming forward + // ----------------------------------------------------------------------- + + sendMessage(request: AgentRequest, config: AgentProcessConfig): SumiReadableStream { + const stream = new SumiReadableStream(); + void this.startSendMessage(request, config, stream); + return stream; + } + + private async startSendMessage( + request: AgentRequest, + config: AgentProcessConfig, + stream: SumiReadableStream, + ): Promise { + let thread = this.sessions.get(request.sessionId); + if (!thread) { + this.logger.log(`[AcpAgentService] sendMessage() — session not active, loading sessionId=${request.sessionId}`); + try { + await this.loadSession(request.sessionId, config); + thread = this.sessions.get(request.sessionId); + } catch (error) { + stream.emitError(normalizeAcpError(error)); return; } + if (!thread) { + stream.emitError(new Error(`No active session for sessionId: ${request.sessionId}`)); + return; + } + } + this.touchSession(request.sessionId); + + // Add user message to thread entries + thread.addUserMessage(request.prompt); + + // 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 }); + } + + this.logger.log( + `[AcpAgentService] sendMessage() — sessionId=${request.sessionId}, thread=${thread.threadId}, entries=${ + thread.getEntries().length + }`, + ); + + // Subscribe thread.onEvent: session_notification -> emitData to stream + const disposables: IDisposable[] = []; - this.handleNotification(notification, stream); + const eventDisposable = thread.onEvent((event: AcpThreadEvent) => { + if (event.type === 'session_notification') { + if (event.notification.sessionId && event.notification.sessionId !== request.sessionId) { + this.logger.warn( + `[AcpAgentService] sendMessage() — ignoring notification for ${event.notification.sessionId}; current session is ${request.sessionId}`, + ); + return; + } + const agentUpdates = toAgentUpdate(event.notification); + const normalizedUpdates = Array.isArray(agentUpdates) ? agentUpdates : []; + if (agentUpdates && !Array.isArray(agentUpdates)) { + normalizedUpdates.push(agentUpdates); + } + for (const agentUpdate of normalizedUpdates) { + agentUpdate.threadStatus = thread.getStatus(); + agentUpdate.sessionId = agentUpdate.sessionId || event.notification.sessionId || request.sessionId; + 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); - - return stream; + // thread.prompt() -> then markAssistantComplete -> emitData('done') -> stream.end() + this.sendPrompt(thread, request, config, stream, disposables); } - /** - * 异步发送 prompt(内部使用) - */ private async sendPrompt( - promptRequest: { sessionId: string; prompt: ContentBlock[] }, + thread: AcpThread, + request: AgentRequest, + config: AgentProcessConfig, stream: SumiReadableStream, + disposables: IDisposable[], ): Promise { try { - await this.clientService.prompt(promptRequest); + const webMcpHintsEnabled = config.webMcp?.enabled !== false && this.builtInMcpSessionIds.has(request.sessionId); + const promptForAgent = await this.withWebMcpCapabilityHint( + request.prompt, + webMcpHintsEnabled && thread.getEntries().length <= 1, + webMcpHintsEnabled, + ); + const promptBlocks = this.buildPromptBlocks(promptForAgent, request.images); + this.logger.log( + `[AcpAgentService] sendPrompt() — sessionId=${request.sessionId}, promptChars=${ + request.prompt.length + }, promptBytes=${Buffer.byteLength(request.prompt, 'utf8')}, sentPromptChars=${ + promptForAgent.length + }, sentPromptBytes=${Buffer.byteLength(promptForAgent, 'utf8')}, images=${ + request.images?.length ?? 0 + }, blocks=${promptBlocks.length}, entries=${thread.getEntries().length}`, + ); + await thread.prompt({ + sessionId: request.sessionId, + prompt: promptBlocks, + } as any); + this.logger.log( + `[AcpAgentService] sendPrompt() — prompt returned, sessionId=${request.sessionId}, thread=${thread.threadId}`, + ); + + thread.markAssistantComplete(); stream.emitData({ type: 'done', content: '' }); stream.end(); } catch (error) { - stream.emitError(error instanceof Error ? error : new Error(String(error))); + this.logger.error( + `[AcpAgentService] sendPrompt() — failed, sessionId=${request.sessionId}, thread=${ + thread.threadId + }, error=${getAcpErrorMessage(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; + } + this.touchSession(sessionId); + + try { + await thread.cancel({ sessionId } as any); + } catch (error) { + this.logger?.warn('[AcpAgentService] cancelRequest error:', error); + } + } + + // ----------------------------------------------------------------------- + // listSessions + // ----------------------------------------------------------------------- + + async listSessions(params?: ListSessionsRequest, config?: AgentProcessConfig): 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, - }); + if (activeThreadCount === 0 && config) { + const thread = await this.findOrCreateIdleThread(config); + try { + if (!thread.initialized) { + await thread.initialize(config as any); } - break; - } - - 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; + if (thread.needsReset) { + thread.reset(); + } + const result = await thread.listSessions(params); + if (result?.sessions) { + for (const info of result.sessions) { + sessionsMap.set(info.sessionId, info); + } + } + if (result?.nextCursor) { + lastNextCursor = result.nextCursor; + } + activeThreadCount = 1; + } catch (error) { + this.logger?.warn(`[AcpAgentService] listSessions error for idle thread, cwd=${thread.cwd}:`, error); + } finally { + this.reservedThreads.delete(thread); } + } - 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}`, - }); - } + // 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}`); + } + this.touchSession(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.syncMaxPoolSize(config); + this.logger.log(`[AcpAgentService] loadSessionOrNew() — sessionId=${sessionId}`); + + const pendingLoad = this.pendingSessionLoads.get(sessionId); + if (pendingLoad) { + pendingLoad.refCount += 1; + return pendingLoad.promise; + } + + const existingThread = this.sessions.get(sessionId); + if (existingThread && existingThread.getStatus() !== 'disconnected') { + this.touchSession(sessionId); + this.retainSession(sessionId); + 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; + + const pending: PendingSessionLoad = { + promise: Promise.resolve(null as unknown as SessionLoadResult), + refCount: 1, + thread, + closeRequested: false, + }; + + const promise = Promise.resolve() + .then(async (): Promise => { + if (!thread.initialized) { + await thread.initialize(config as any); + } + if (thread.needsReset) { + thread.reset(); + } + const mcpServers = await this.getSessionMcpServers(thread, config); + const loadResult = await thread.loadSessionOrNew({ + sessionId, + cwd: config.cwd, + mcpServers, + } as any); + const actualSessionId = (loadResult as { sessionId?: string }).sessionId || sessionId; + if (pending.closeRequested) { + throw new Error(`Session load was disposed before completion: ${sessionId}`); + } + if (actualSessionId !== sessionId) { + this.sessions.delete(sessionId); + this.sessionRefCounts.delete(sessionId); + this.permissionRouting.unregisterSession(sessionId); + this.builtInMcpSessionIds.delete(sessionId); + this.unregisterThreadStatusListener(sessionId); + this.bindSession(actualSessionId, thread); + this.sessionRefCounts.set(actualSessionId, pending.refCount); + this.permissionRouting.registerSession(actualSessionId); + this.registerThreadStatusListener(actualSessionId, thread); + } else { + this.sessionRefCounts.set(sessionId, pending.refCount); + } + this.setBuiltInMcpSessionState(actualSessionId, this.didAppendBuiltInMcpServer(config, mcpServers)); + await this.applyDefaultSessionOptions(actualSessionId, thread, config); + return this.buildSessionLoadResult(actualSessionId, thread); + }) + .catch(async (e) => { + this.sessions.delete(sessionId); + this.sessionRefCounts.delete(sessionId); + this.permissionRouting.unregisterSession(sessionId); + this.builtInMcpSessionIds.delete(sessionId); + this.unregisterThreadStatusListener(sessionId); + if (!wasExisting) { + const idx = this.threadPool.indexOf(thread); + if (idx !== -1) { + this.threadPool.splice(idx, 1); } + await thread.dispose(); + } else { + thread.reset(); } - break; + throw e; + }) + .finally(() => { + this.pendingSessionLoads.delete(sessionId); + }); + + pending.promise = promise; + this.pendingSessionLoads.set(sessionId, pending); + this.reservedThreads.delete(thread); + return promise; + } + + // ----------------------------------------------------------------------- + // setSessionConfigOption + // ----------------------------------------------------------------------- + + 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}`); + } + this.touchSession(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}`); } + this.touchSession(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}`); + } + this.touchSession(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}`); + } + this.touchSession(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}`); + } + this.touchSession(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 { + let thread = this.sessions.get(sessionId); + this.logger.log(`[AcpAgentService] disposeSession() — sessionId=${sessionId}, force=${force}`); + + const pendingLoad = this.pendingSessionLoads.get(sessionId); + if (pendingLoad) { + pendingLoad.closeRequested = true; + if (!force) { + pendingLoad.refCount = Math.max(0, pendingLoad.refCount - 1); + if (pendingLoad.refCount > 0) { + pendingLoad.closeRequested = false; + this.logger.log( + `[AcpAgentService] disposeSession() — pending load still retained, sessionId=${sessionId}, refs=${pendingLoad.refCount}`, + ); + return; + } + try { + await pendingLoad.promise; + } catch { + // The pending load path owns its failure cleanup. Continue with the + // normal release path to keep terminal/session cleanup idempotent. + } + } + thread = this.sessions.get(sessionId) ?? pendingLoad.thread; + } + + const refCount = this.sessionRefCounts.get(sessionId) ?? (thread ? 1 : 0); + if (!force && refCount > 1) { + this.sessionRefCounts.set(sessionId, refCount - 1); + this.logger.log( + `[AcpAgentService] disposeSession() — session still retained, sessionId=${sessionId}, refs=${refCount - 1}`, + ); + return; + } + + // 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.sessionRefCounts.delete(sessionId); + this.logPoolStatus('after-disposeSession'); + this.builtInMcpSessionIds.delete(sessionId); + } + + // ----------------------------------------------------------------------- + // 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; } - async getAvailableModes() { - return this.clientService.getSessionModes(); + async getAcpDebugLog(): Promise { + return acpDebugLogStore.getEntries(); } - /** - * 停止 Agent 进程 - */ + async clearAcpDebugLog(): Promise { + acpDebugLogStore.clear(); + } + + // ----------------------------------------------------------------------- + // 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.pendingSessionLoads.clear(); + this.reservedThreads.clear(); + this.sessionRefCounts.clear(); + this.lastSessionInfo = null; + this.builtInMcpSessionIds.clear(); + 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, + modes: Array<{ id: string; name: string }>, + ): void { + this.lastSessionInfo = { + sessionId, + processId: thread.threadId, + modes, + status: 'ready', + }; + } + + private retainSession(sessionId: string): void { + this.sessionRefCounts.set(sessionId, (this.sessionRefCounts.get(sessionId) ?? 1) + 1); } - 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', @@ -606,6 +1797,17 @@ export class AcpAgentService implements IAcpAgentService { return blocks; } + private async withWebMcpCapabilityHint( + input: string, + includeHint: boolean, + webMcpHintsEnabled = true, + ): Promise { + if (!webMcpHintsEnabled || !includeHint) { + return input; + } + return `${WEBMCP_CAPABILITY_HINT}\n\n${input}`; + } + private parseDataUrl(dataUrl: string): { mimeType: string; base64Data: string } { if (dataUrl.startsWith('data:')) { const matches = dataUrl.match(/^data:([^;]+);base64,(.+)$/); @@ -613,7 +1815,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-browser-rpc-bridge.service.ts b/packages/ai-native/src/node/acp/acp-browser-rpc-bridge.service.ts new file mode 100644 index 0000000000..bb7e7fb288 --- /dev/null +++ b/packages/ai-native/src/node/acp/acp-browser-rpc-bridge.service.ts @@ -0,0 +1,114 @@ +import { Autowired, Injectable } from '@opensumi/di'; +import { CLIENT_ID_TOKEN } from '@opensumi/ide-core-common'; + +import { AcpBrowserRpcRegistry } from './acp-browser-rpc-registry'; + +import type { IDisposable } from '@opensumi/ide-core-common'; +import type { + IAcpPermissionService, + IAcpThreadStatusService, + IAcpWebMcpBridgeService, +} from '@opensumi/ide-core-common/lib/types/ai-native/acp-types'; + +export const AcpPermissionRpcBridgeServiceToken = Symbol('AcpPermissionRpcBridgeServiceToken'); +export const AcpThreadStatusRpcBridgeServiceToken = Symbol('AcpThreadStatusRpcBridgeServiceToken'); +export const AcpWebMcpRpcBridgeServiceToken = Symbol('AcpWebMcpRpcBridgeServiceToken'); + +@Injectable() +export class AcpPermissionRpcBridgeService { + @Autowired(CLIENT_ID_TOKEN) + private readonly clientId: string; + + @Autowired(AcpBrowserRpcRegistry) + private readonly browserRpcRegistry: AcpBrowserRpcRegistry; + + private clients: IAcpPermissionService[] | undefined; + private registration: IDisposable | undefined; + + set rpcClient(clients: IAcpPermissionService[] | undefined) { + this.clients = clients; + this.registration?.dispose(); + this.registration = undefined; + + const client = clients?.[0]; + if (client) { + this.registration = this.browserRpcRegistry.registerPermissionClient(this.clientId, client); + } + } + + get rpcClient(): IAcpPermissionService[] | undefined { + return this.clients; + } + + dispose(): void { + this.registration?.dispose(); + this.registration = undefined; + this.clients = undefined; + } +} + +@Injectable() +export class AcpThreadStatusRpcBridgeService { + @Autowired(CLIENT_ID_TOKEN) + private readonly clientId: string; + + @Autowired(AcpBrowserRpcRegistry) + private readonly browserRpcRegistry: AcpBrowserRpcRegistry; + + private clients: IAcpThreadStatusService[] | undefined; + private registration: IDisposable | undefined; + + set rpcClient(clients: IAcpThreadStatusService[] | undefined) { + this.clients = clients; + this.registration?.dispose(); + this.registration = undefined; + + const client = clients?.[0]; + if (client) { + this.registration = this.browserRpcRegistry.registerThreadStatusClient(this.clientId, client); + } + } + + get rpcClient(): IAcpThreadStatusService[] | undefined { + return this.clients; + } + + dispose(): void { + this.registration?.dispose(); + this.registration = undefined; + this.clients = undefined; + } +} + +@Injectable() +export class AcpWebMcpRpcBridgeService { + @Autowired(CLIENT_ID_TOKEN) + private readonly clientId: string; + + @Autowired(AcpBrowserRpcRegistry) + private readonly browserRpcRegistry: AcpBrowserRpcRegistry; + + private clients: IAcpWebMcpBridgeService[] | undefined; + private registration: IDisposable | undefined; + + set rpcClient(clients: IAcpWebMcpBridgeService[] | undefined) { + this.clients = clients; + this.registration?.dispose(); + this.registration = undefined; + + const client = clients?.[0]; + if (client) { + this.registration = this.browserRpcRegistry.registerWebMcpClient(this.clientId, client); + } + } + + get rpcClient(): IAcpWebMcpBridgeService[] | undefined { + return this.clients; + } + + dispose(): void { + this.registration?.dispose(); + this.registration = undefined; + this.clients = undefined; + } +} diff --git a/packages/ai-native/src/node/acp/acp-browser-rpc-registry.ts b/packages/ai-native/src/node/acp/acp-browser-rpc-registry.ts new file mode 100644 index 0000000000..2a752e439a --- /dev/null +++ b/packages/ai-native/src/node/acp/acp-browser-rpc-registry.ts @@ -0,0 +1,70 @@ +import { Injectable } from '@opensumi/di'; + +import type { IDisposable } from '@opensumi/ide-core-common'; +import type { + IAcpPermissionService, + IAcpThreadStatusService, + IAcpWebMcpBridgeService, +} from '@opensumi/ide-core-common/lib/types/ai-native/acp-types'; + +@Injectable() +export class AcpBrowserRpcRegistry { + private readonly permissionClients = new Map(); + private readonly threadStatusClients = new Map(); + private readonly webMcpClients = new Map(); + + registerPermissionClient(clientId: string, client: IAcpPermissionService): IDisposable { + return this.registerClient(this.permissionClients, clientId, client); + } + + getPermissionClient(clientId?: string): IAcpPermissionService | undefined { + return this.getClient(this.permissionClients, clientId); + } + + registerThreadStatusClient(clientId: string, client: IAcpThreadStatusService): IDisposable { + return this.registerClient(this.threadStatusClients, clientId, client); + } + + getThreadStatusClient(clientId?: string): IAcpThreadStatusService | undefined { + return this.getClient(this.threadStatusClients, clientId); + } + + registerWebMcpClient(clientId: string, client: IAcpWebMcpBridgeService): IDisposable { + return this.registerClient(this.webMcpClients, clientId, client); + } + + getWebMcpClient(clientId?: string): IAcpWebMcpBridgeService | undefined { + return this.getClient(this.webMcpClients, clientId); + } + + dispose(): void { + this.permissionClients.clear(); + this.threadStatusClients.clear(); + this.webMcpClients.clear(); + } + + private registerClient(clients: Map, clientId: string, client: T): IDisposable { + clients.delete(clientId); + clients.set(clientId, client); + + return { + dispose: () => { + if (clients.get(clientId) === client) { + clients.delete(clientId); + } + }, + }; + } + + private getClient(clients: Map, clientId?: string): T | undefined { + if (clientId) { + return clients.get(clientId); + } + + let current: T | undefined; + for (const client of clients.values()) { + current = client; + } + return current; + } +} 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..2b56f61267 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 @@ -1,6 +1,7 @@ import { Autowired, Injectable } from '@opensumi/di'; import { AvailableCommand, + CLIENT_ID_TOKEN, CancellationToken, IAIBackService, IAIBackServiceOption, @@ -8,10 +9,15 @@ import { IChatContent, IChatProgress, IChatReasoning, - ListSessionsRequest, + IChatSessionState, + 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 +26,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 { getAcpErrorMessage, normalizeAcpError } from './acp-error'; +import { AcpThreadStatusCallerServiceToken } from './acp-thread-status-caller.service'; import type { CoreMessage } from 'ai'; @@ -94,15 +95,46 @@ export class AcpCliBackService implements IAIBackService { @Autowired(AcpAgentServiceToken) private agentService: IAcpAgentService; + @Autowired(CLIENT_ID_TOKEN) + private readonly clientId: string | undefined; + @Autowired(INodeLogger) private readonly logger: INodeLogger; @Autowired(OpenAICompatibleModel) private openAICompatibleModel: OpenAICompatibleModel; + @Autowired(AcpThreadStatusCallerServiceToken) + private threadStatusCaller: any; + private isDisposing = false; - // private registerProcessExitHandlers(): void { + private threadStatusDisposable: any; + + async getOpenSumiMcpServerConnection() { + return this.agentService.getOpenSumiMcpServerConnection(this.clientId); + } + + /** + * 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,30 +148,197 @@ export class AcpCliBackService implements IAIBackService { // }); // } - async createSession( - config: AgentProcessConfig, - ): Promise<{ sessionId: string; availableCommands: AvailableCommand[] }> { - await this.ensureAgentInitialized(config); - return this.agentService.createSession(config); + async request( + input: string, + options: IAIBackServiceOption, + cancelToken?: CancellationToken, + ): Promise { + this.logger.log( + `[ACP Back] request: type=${ + options.type ?? '(empty)' + }, hasAgentSessionConfig=${!!options.agentSessionConfig}, noTool=${options.noTool === true}`, + ); + if (!options.agentSessionConfig) { + return this.openAIRequest(input, options, cancelToken); + } + return this.agentRequest(input, options, cancelToken); } - private async ensureAgentInitialized(config: AgentProcessConfig): Promise { - const existingSession = this.agentService.getSessionInfo(); - if (existingSession) { - return existingSession; + private async openAIRequest( + input: string, + options: IAIBackServiceOption, + cancelToken?: CancellationToken, + ): Promise> { + const stream = new ChatReadableStream(); + const responsePromise = this.collectChatProgressStream(stream); + try { + await this.openAICompatibleModel.request(input, stream, options, cancelToken); + return responsePromise; + } catch (error) { + const normalizedError = normalizeAcpError(error); + return { + errorCode: -1, + errorMsg: normalizedError.message, + }; } - return this.agentService.initializeAgent(config); } - async request( + private async agentRequest( input: string, options: IAIBackServiceOption, cancelToken?: CancellationToken, - ): Promise { - return { - errorCode: -1, - errorMsg: 'request() is not supported. ', - } as IAIBackServiceResponse; + ): Promise> { + let sessionId: string | undefined; + try { + this.ensureThreadStatusSubscription(); + const config: AgentProcessConfig = { + ...options.agentSessionConfig!, + mcpServers: options.noTool ? [] : options.agentSessionConfig!.mcpServers, + }; + const result = await this.agentService.createSession(config); + sessionId = result.sessionId; + this.logger.log( + `[ACP Back] request: created ephemeral session sessionId=${sessionId}, type=${options.type ?? '(empty)'}`, + ); + + const stream = this.agentService.sendMessage( + { + sessionId, + prompt: this.buildNonStreamingAgentPrompt(input, options), + images: options.images, + history: convertMessageHistory(options.history), + }, + config, + ); + + return await this.collectAgentRequestStream(stream, sessionId, cancelToken); + } catch (error) { + if (sessionId) { + await this.disposeEphemeralSession(sessionId); + } + const normalizedError = normalizeAcpError(error); + return { + errorCode: -1, + errorMsg: normalizedError.message, + }; + } + } + + private collectAgentRequestStream( + stream: SumiReadableStream, + sessionId: string, + cancelToken?: CancellationToken, + ): Promise> { + let content = ''; + let settled = false; + const disposables: Array<{ dispose(): void }> = []; + + return new Promise((resolve) => { + const finish = async (response: IAIBackServiceResponse) => { + if (settled) { + return; + } + settled = true; + disposables.forEach((disposable) => disposable.dispose()); + await this.disposeEphemeralSession(sessionId); + resolve(response); + }; + + disposables.push( + stream.onData((update) => { + if (update.type === 'message') { + content += update.content; + return; + } + if (update.type === 'done') { + finish({ + errorCode: 0, + data: content, + }); + } + }), + ); + disposables.push( + stream.onEnd(() => { + finish({ + errorCode: 0, + data: content, + }); + }), + ); + disposables.push( + stream.onError((error) => { + const normalizedError = normalizeAcpError(error); + finish({ + errorCode: -1, + errorMsg: normalizedError.message, + }); + }), + ); + if (cancelToken) { + disposables.push( + cancelToken.onCancellationRequested(() => { + this.agentService.cancelRequest(sessionId).finally(() => { + finish({ + errorCode: -1, + errorMsg: 'Request canceled', + isCancel: true, + }); + }); + }), + ); + } + }); + } + + private collectChatProgressStream( + stream: SumiReadableStream, + ): Promise> { + let content = ''; + return new Promise((resolve) => { + stream.onData((progress) => { + if (progress.kind === 'content') { + content += progress.content; + } + }); + stream.onEnd(() => { + resolve({ + errorCode: 0, + data: content, + }); + }); + stream.onError((error) => { + const normalizedError = normalizeAcpError(error); + resolve({ + errorCode: -1, + errorMsg: normalizedError.message, + }); + }); + }); + } + + private buildNonStreamingAgentPrompt(input: string, options: IAIBackServiceOption): string { + if (!options.noTool) { + return input; + } + return `You are running in a temporary background session for a non-interactive OpenSumi request. +Do not call tools, do not inspect files, and do not ask follow-up questions. Return only the final answer text. + +${input}`; + } + + private async disposeEphemeralSession(sessionId: string): Promise { + try { + await this.agentService.closeSession({ sessionId }); + } catch (error) { + this.logger.warn(`[ACP Back] request: failed to close ephemeral session sessionId=${sessionId}`, error); + } + try { + await this.agentService.disposeSession(sessionId, true); + this.logger.log(`[ACP Back] request: disposed ephemeral session sessionId=${sessionId}`); + } catch (error) { + this.logger.warn(`[ACP Back] request: failed to dispose ephemeral session sessionId=${sessionId}`, error); + } } async requestStream( @@ -147,10 +346,21 @@ 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}, requestId=${ + options.requestId ?? '(empty)' + }, inputChars=${input.length}, images=${options.images?.length ?? 0}, historyMessages=${ + options.history?.length ?? 0 + }`, + ); // 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 +369,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 +388,12 @@ export class AcpCliBackService implements IAIBackService { options: IAIBackServiceOption, cancelToken?: CancellationToken, ): SumiReadableStream { + this.logger.log( + `[ACP Back] agentRequestStream: setting up agent stream, sessionId=${options.sessionId ?? '(empty)'}, requestId=${ + options.requestId ?? '(empty)' + }, inputChars=${input.length}`, + ); + this.ensureThreadStatusSubscription(); const stream = new SumiReadableStream(); this.setupAgentStream(options.agentSessionConfig!, input, options, stream, cancelToken); return stream; @@ -186,13 +407,24 @@ 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}, requestId=${ + options.requestId ?? '(empty)' + }`, + ); + + let sessionId = options.sessionId; + if (!sessionId) { + this.logger.log( + `[ACP Back] setupAgentStream: no sessionId, creating session for requestId=${options.requestId ?? '(empty)'}`, + ); + const result = await this.agentService.createSession(config); + sessionId = result.sessionId; + this.logger.log( + `[ACP Back] setupAgentStream: created sessionId=${sessionId}, requestId=${options.requestId ?? '(empty)'}`, + ); } - const sessionInfo = await this.ensureAgentInitialized(options.agentSessionConfig); - const sessionId = options.sessionId || sessionInfo.sessionId; - const request: AgentRequest = { sessionId, prompt: input, @@ -200,32 +432,87 @@ export class AcpCliBackService implements IAIBackService { history: convertMessageHistory(options.history), }; + this.logger.log( + `[ACP Back] setupAgentStream: sending message, sessionId=${sessionId}, requestId=${ + options.requestId ?? '(empty)' + }, promptChars=${input.length}`, + ); + const agentStream = this.agentService.sendMessage(request, config); + const toolCallCache = new Map(); + let agentUpdateCount = 0; + let hasLoggedFirstContent = false; cancelToken?.onCancellationRequested(async () => { + this.logger.warn( + `[ACP Back] setupAgentStream: cancellation requested, sessionId=${sessionId}, requestId=${ + options.requestId ?? '(empty)' + }`, + ); await this.agentService.cancelRequest(sessionId); stream.end(); }); agentStream.onData((update: AgentUpdate) => { - const progress = this.convertAgentUpdateToChatProgress(update); + agentUpdateCount += 1; + const shouldLogUpdate = + !hasLoggedFirstContent || (update.type !== 'message' && update.type !== 'thought' && update.type !== 'done'); + if (shouldLogUpdate) { + this.logger.log( + `[ACP Back] agentStream onData: sessionId=${request.sessionId}, requestId=${ + options.requestId ?? '(empty)' + }, type=${update.type}, count=${agentUpdateCount}, threadStatus=${update.threadStatus ?? '(empty)'}`, + ); + hasLoggedFirstContent = true; + } + const progress = this.convertAgentUpdateToChatProgress(update, toolCallCache); 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') { + this.logger.log( + `[ACP Back] agentStream done: sessionId=${request.sessionId}, requestId=${ + options.requestId ?? '(empty)' + }, updates=${agentUpdateCount}`, + ); stream.end(); } }); agentStream.onError((error) => { - stream.emitError(error instanceof Error ? error : new Error(String(error))); + this.logger.error( + `[ACP Back] agentStream onError: sessionId=${request.sessionId}, requestId=${ + options.requestId ?? '(empty)' + }, updates=${agentUpdateCount}`, + error, + ); + stream.emitError(normalizeAcpError(error)); }); } catch (error) { - stream.emitError(error instanceof Error ? error : new Error(String(error))); + this.logger.error( + `[ACP Back] setupAgentStream catch: sessionId=${options.sessionId ?? '(empty)'}, requestId=${ + options.requestId ?? '(empty)' + }`, + error, + ); + stream.emitError(normalizeAcpError(error)); } } - private convertAgentUpdateToChatProgress(update: AgentUpdate): IChatProgress | null { + private convertAgentUpdateToChatProgress( + update: AgentUpdate, + toolCallCache: Map, + ): IChatProgress | null { switch (update.type) { case 'thought': return { @@ -237,40 +524,120 @@ export class AcpCliBackService implements IAIBackService { kind: 'content', content: update.content, } as IChatContent; - case 'tool_call': - return null; - case 'tool_result': + case 'session_state': + return { + kind: 'sessionState', + sessionId: update.sessionId || '', + ...(update.currentModeId !== undefined ? { currentModeId: update.currentModeId } : {}), + ...(update.currentModelId !== undefined ? { currentModelId: update.currentModelId } : {}), + ...(update.configOptions !== undefined ? { configOptions: update.configOptions } : {}), + } as IChatSessionState; + case 'tool_call': { + const toolCall: IChatToolCall = { + id: update.toolCall?.toolCallId || '', + type: 'function', + function: { + name: update.toolCall?.name || update.content, + arguments: update.toolCall?.input !== undefined ? JSON.stringify(update.toolCall.input) ?? '' : '', + }, + state: 'complete', + }; + if (toolCall.id) { + toolCallCache.set(toolCall.id, toolCall); + } + 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_call_args': { + const toolCallId = update.toolCall?.toolCallId; + const cached = toolCallId ? toolCallCache.get(toolCallId) : undefined; + if (!toolCallId || !cached) { + return null; + } + const updated: IChatToolCall = { + ...cached, + function: { + ...cached.function, + arguments: JSON.stringify(update.toolCall?.input) ?? '', + }, + }; + toolCallCache.set(toolCallId, updated); + return { + kind: 'toolCall', + content: updated, + } as IChatToolContent; + } + case 'tool_result': { + const toolCallId = update.toolCall?.toolCallId; + if (toolCallId) { + const cached = toolCallCache.get(toolCallId); + const updated: IChatToolCall = cached + ? { + ...cached, + result: update.content, + state: 'result', + } + : { + id: toolCallId, + type: 'function', + function: { + name: update.toolCall?.name || '', + arguments: '', + }, + result: update.content, + state: 'result', + }; + toolCallCache.set(toolCallId, updated); + return { + kind: 'toolCall', + content: updated, + } as IChatToolContent; + } + 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; } } - async loadAgentSession( - config: AgentProcessConfig, - sessionId: string, - ): Promise<{ - sessionId: string; - messages: Array<{ - role: 'user' | 'assistant'; - content: string; - timestamp?: number; - }>; - }> { + async loadAgentSession(config: AgentProcessConfig, sessionId: string) { try { const result = await this.agentService.loadSession(sessionId, config); const messages = this.convertSessionUpdatesToMessages(result.historyUpdates); return { - sessionId, + sessionId: result.sessionId, messages, + modes: result.modes, + currentModeId: result.currentModeId, + models: result.models, + currentModelId: result.currentModelId, + configOptions: result.configOptions, }; } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); + const errorMessage = getAcpErrorMessage(error); this.logger.error(`Failed to load session ${sessionId}:`, errorMessage); // 抛出错误,让调用方感知实际错误 @@ -344,22 +711,14 @@ export class AcpCliBackService implements IAIBackService { } } - async listSessions(config: AgentProcessConfig): Promise { - const listParams: ListSessionsRequest = { - cwd: config.workspaceDir, - }; - await this.ensureAgentInitialized(config); + async createSession(config: AgentProcessConfig) { + 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, config); } async dispose(): Promise { @@ -376,4 +735,49 @@ export class AcpCliBackService implements IAIBackService { async ready(): Promise { return true; } + + async loadSessionOrNew(config: AgentProcessConfig, sessionId: string) { + const result = await this.agentService.loadSessionOrNew(sessionId, config); + const messages = this.convertSessionUpdatesToMessages(result.historyUpdates); + return { + sessionId: result.sessionId, + messages, + modes: result.modes, + currentModeId: result.currentModeId, + models: result.models, + currentModelId: result.currentModelId, + configOptions: result.configOptions, + }; + } + + 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 }); + } + + async getAcpDebugLog() { + return this.agentService.getAcpDebugLog(); + } + + async clearAcpDebugLog(): Promise { + await this.agentService.clearAcpDebugLog(); + } } 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-debug-log.ts b/packages/ai-native/src/node/acp/acp-debug-log.ts new file mode 100644 index 0000000000..9a2cc0f5bd --- /dev/null +++ b/packages/ai-native/src/node/acp/acp-debug-log.ts @@ -0,0 +1,84 @@ +import type { AcpDebugLogDirection, AcpDebugLogEntry } from '@opensumi/ide-core-common/lib/types/ai-native/acp-types'; + +const MAX_ACP_DEBUG_LOG_ENTRIES = 2000; + +export interface AcpDebugLogRecordInput { + direction: AcpDebugLogDirection; + agentId: string; + threadId: string; + sessionId?: string; + raw: string; + payload?: unknown; +} + +export class AcpDebugLogStore { + private entries: AcpDebugLogEntry[] = []; + private nextId = 1; + private threadSessionIds = new Map(); + + record(input: AcpDebugLogRecordInput): AcpDebugLogEntry { + const raw = input.raw.trimEnd(); + const entry: AcpDebugLogEntry = { + id: this.nextId++, + timestamp: Date.now(), + direction: input.direction, + agentId: input.agentId, + threadId: input.threadId, + sessionId: input.sessionId ?? this.threadSessionIds.get(input.threadId), + raw, + payload: input.payload !== undefined ? input.payload : this.tryParsePayload(raw), + }; + this.entries.push(entry); + if (this.entries.length > MAX_ACP_DEBUG_LOG_ENTRIES) { + this.entries.splice(0, this.entries.length - MAX_ACP_DEBUG_LOG_ENTRIES); + } + return this.clone(entry); + } + + createLineRecorder(context: Omit): (chunk: Uint8Array | Buffer | string) => void { + let buffer = ''; + return (chunk) => { + buffer += typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8'); + const lines = buffer.split(/\r?\n/); + buffer = lines.pop() ?? ''; + for (const line of lines) { + if (line.length === 0) { + continue; + } + this.record({ ...context, raw: line }); + } + }; + } + + setThreadSessionId(threadId: string, sessionId: string): void { + this.threadSessionIds.set(threadId, sessionId); + for (const entry of this.entries) { + if (entry.threadId === threadId && !entry.sessionId) { + entry.sessionId = sessionId; + } + } + } + + getEntries(): AcpDebugLogEntry[] { + return this.entries.map((entry) => this.clone(entry)); + } + + clear(): void { + this.entries = []; + this.nextId = 1; + } + + private tryParsePayload(raw: string): unknown | undefined { + try { + return JSON.parse(raw); + } catch { + return undefined; + } + } + + private clone(entry: AcpDebugLogEntry): AcpDebugLogEntry { + return JSON.parse(JSON.stringify(entry)); + } +} + +export const acpDebugLogStore = new AcpDebugLogStore(); 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..ecfe5e7a38 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,11 @@ import { Autowired, Injectable } from '@opensumi/di'; import { RPCService } from '@opensumi/ide-connection'; -import { INodeLogger } from '@opensumi/ide-core-node'; + +import { AcpBrowserRpcRegistry } from './acp-browser-rpc-registry'; import type { AcpPermissionDecision, AcpPermissionDialogParams, - IAcpPermissionCaller, IAcpPermissionService, PermissionOption, PermissionOptionKind, @@ -13,58 +13,44 @@ 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. + * + * Browser RPC clients are registered by per-connection bridge services in + * AcpBrowserRpcRegistry. This keeps connection wiring inside ai-native while + * allowing parent-injector consumers to reach the active browser connection. * + * Each call to requestPermission() independently invokes + * this.client or the active registered 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 { + @Autowired(AcpBrowserRpcRegistry) + private readonly browserRpcRegistry: AcpBrowserRpcRegistry; /** - * 当前活跃的 RPC 客户端(所有连接共享) - * + * Get the RPC client from the instance or from the active browser bridge. */ - private static currentRpcClient: IAcpPermissionService | null = null; - - private clientId: string | undefined; - - /** - * 设置连接 clientId - * - * 注意:框架调用 setConnectionClientId 后才设置 rpcClient, - * 因此需要使用微任务延迟赋值,确保 rpcClient 已经准备好 - */ - setConnectionClientId(clientId: string): void { - this.clientId = clientId; - - Promise.resolve().then(() => { - AcpPermissionCallerManager.currentRpcClient = this.client || null; - }); - } - - removeConnectionClientId(clientId: string): void { - if (this.clientId === clientId) { - if (AcpPermissionCallerManager.currentRpcClient === this.client) { - AcpPermissionCallerManager.currentRpcClient = null; - } - this.clientId = undefined; - } + private getRpcClient(): IAcpPermissionService | undefined { + return this.client ?? this.browserRpcRegistry?.getPermissionClient(); } /** - * 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 +59,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 +139,7 @@ export class AcpPermissionCallerManager extends RPCService allow_once > reject_always > reject_once */ private sortOptionsByKind(options: PermissionOption[]): PermissionOption[] { @@ -220,3 +201,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..8e374499a6 --- /dev/null +++ b/packages/ai-native/src/node/acp/acp-thread-status-caller.service.ts @@ -0,0 +1,33 @@ +import { Autowired, Injectable } from '@opensumi/di'; +import { RPCService } from '@opensumi/ide-connection'; + +import { AcpBrowserRpcRegistry } from './acp-browser-rpc-registry'; + +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 AcpBrowserRpcRegistry to reach the active per-connection browser RPC + * bridge from parent-injector consumers. + */ +@Injectable() +export class AcpThreadStatusCallerService extends RPCService { + @Autowired(AcpBrowserRpcRegistry) + private readonly browserRpcRegistry: AcpBrowserRpcRegistry; + + private getRpcClient(): IAcpThreadStatusService | undefined { + return this.client ?? this.browserRpcRegistry?.getThreadStatusClient(); + } + + 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..1abf062d3e --- /dev/null +++ b/packages/ai-native/src/node/acp/acp-thread.ts @@ -0,0 +1,1630 @@ +/** + * 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 { + AcpDebugLogDirection, + AgentCapabilities, + AvailableCommand, + 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 { AgentProcessConfig } from '@opensumi/ide-core-common/lib/types/ai-native/agent-types'; +import { INodeLogger } from '@opensumi/ide-core-node'; + +import { acpDebugLogStore } from './acp-debug-log'; +import { resolveAgentSpawnConfig } from './acp-spawn-config'; +import { AcpFileSystemHandler, AcpFileSystemHandlerToken } from './handlers/file-system.handler'; +import { AcpTerminalHandler, AcpTerminalHandlerToken } from './handlers/terminal.handler'; +import { PermissionRoutingService, PermissionRoutingServiceToken } from './permission-routing.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, + onChunk?: (chunk: Uint8Array | Buffer | string) => void, +): ReadableStream { + return new streamWeb.ReadableStream({ + start(controller) { + readable.on('data', (chunk: Buffer) => { + onChunk?.(chunk); + 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, + onChunk?: (chunk: Uint8Array | Buffer | string) => void, +): WritableStream { + return new streamWeb.WritableStream({ + write(chunk) { + onChunk?.(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; +const ACP_AGENT_CONNECTION_CLOSED_DURING_PROMPT = 'ACP agent connection closed while waiting for prompt response.'; + +function isConnectionClosedDuringPromptError(error: unknown): boolean { + return error instanceof Error && error.message === ACP_AGENT_CONNECTION_CLOSED_DURING_PROMPT; +} + +// --------------------------------------------------------------------------- +// 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; +} + +export interface AcpSessionInfoState { + _meta?: { [key: string]: unknown } | null; + title?: string | null; + updatedAt?: string | null; +} + +export interface AcpSessionState { + notifications: ReadonlyArray; + entries: ReadonlyArray; + modes?: ReadonlyArray<{ id: string; name: string; description?: string | null }>; + currentModeId?: string; + models?: ReadonlyArray<{ modelId: string; name: string; description?: string | null }>; + currentModelId?: string; + configOptions?: ReadonlyArray; + usage?: unknown; + sessionInfo?: AcpSessionInfoState; + availableCommands?: ReadonlyArray; +} + +/** 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; + getSessionNotifications(): ReadonlyArray; + getSessionState(): AcpSessionState; + 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; +} + +// --------------------------------------------------------------------------- +// 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); + + 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, + }); + }, +}; + +// --------------------------------------------------------------------------- +// 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 _sessionNotifications: SessionNotification[] = []; + private _sessionId: string = ''; + private _needsReset = false; + private _agentCapabilities: AgentCapabilities | null = null; + private _initialized = false; + private _modes: Array<{ id: string; name: string; description?: string | null }> | undefined; + private _currentModeId: string | undefined; + private _models: Array<{ modelId: string; name: string; description?: string | null }> | undefined; + private _currentModelId: string | undefined; + private _configOptions: unknown[] | undefined; + private _usage: unknown; + private _sessionInfo: AcpSessionInfoState | undefined; + private _availableCommands: AvailableCommand[] | undefined; + + // Process + private _childProcess: ChildProcess | null = null; + private _processRunning = false; + private _debugLogRecorders = new Map void>(); + + // SDK + private _connection: any = null; // ClientSideConnection instance + private _connected = false; + + // 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; + } + + getSessionNotifications(): ReadonlyArray { + return this._sessionNotifications.map((notification) => this.cloneSessionNotification(notification)); + } + + getSessionState(): AcpSessionState { + return { + notifications: this.getSessionNotifications(), + entries: this._entries, + modes: this._modes ? [...this._modes] : undefined, + currentModeId: this._currentModeId, + models: this._models ? [...this._models] : undefined, + currentModelId: this._currentModelId, + configOptions: this._configOptions ? [...this._configOptions] : undefined, + usage: this._usage, + sessionInfo: this._sessionInfo ? { ...this._sessionInfo } : undefined, + availableCommands: this._availableCommands ? [...this._availableCommands] : undefined, + }; + } + + 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.recordDebugLog('stderr', data); + 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.recordDebugLog('system', `process started: ${resolved.command} ${resolved.args.join(' ')}`); + 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, (chunk) => this.recordDebugLog('outgoing', chunk)); + const webInputStream = nodeReadableToWebStream(stdout, (chunk) => this.recordDebugLog('incoming', chunk)); + + const stream = ndJsonStream(webOutputStream, webInputStream); + + const clientImpl = this.createClientImpl(); + this._connection = new ClientSideConnection((_agent: any) => clientImpl, stream); + + this._connected = true; + } + + private async rejectOnConnectionClosed(operation: Promise, message: string): Promise { + const closed = this._connection?.closed; + if (!closed || typeof closed.then !== 'function') { + return operation; + } + + let settled = false; + const closedPromise = new Promise((_resolve, reject) => { + void closed.then(() => { + if (!settled) { + reject(new Error(message)); + } + }); + }); + + try { + return await Promise.race([operation, closedPromise]); + } finally { + settled = true; + } + } + + private createClientImpl(): any { + const self = this; + + return { + async requestPermission(params: RequestPermissionRequest): Promise { + return self.handlePermissionRequest(params); + }, + + async sessionUpdate(params: SessionNotification): Promise { + if (!self.isCurrentSessionNotification(params)) { + return; + } + self.recordSessionNotification(params); + 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); + } + }, + }; + } + + // ----------------------------------------------------------------------- + // 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(); + + const initParams: InitializeRequest = { + protocolVersion: ACP_PROTOCOL_VERSION, + clientCapabilities: { + fs: { + readTextFile: true, + writeTextFile: true, + }, + terminal: true, + }, + 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 || {}), + }; + } + + 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; + acpDebugLogStore.setThreadSessionId(this.threadId, response.sessionId); + this._needsReset = true; + this.applySessionInitialState(response); + 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}`); + + this._sessionId = params.sessionId; + acpDebugLogStore.setThreadSessionId(this.threadId, params.sessionId); + const response: LoadSessionResponse = await this._connection.loadSession(params); + this._needsReset = true; + this.applySessionInitialState(response); + 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'); + + let response: PromptResponse; + try { + response = await this.rejectOnConnectionClosed( + this._connection.prompt(params), + ACP_AGENT_CONNECTION_CLOSED_DURING_PROMPT, + ); + } catch (error) { + if (this._status === 'working') { + const nextStatus = isConnectionClosedDuringPromptError(error) ? 'disconnected' : 'awaiting_prompt'; + this.setStatus(nextStatus); + this.logger?.log( + `[AcpThread:${this.threadId}] prompt() — failed, status→${nextStatus}, entries=${this._entries.length}`, + ); + } + throw error; + } + + // 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(); + const response = await this._connection.setSessionMode(params); + this._currentModeId = params.modeId; + return response; + } + + async setSessionConfigOption(params: SetSessionConfigOptionRequest): Promise { + this.logger?.log(`[AcpThread:${this.threadId}] setSessionConfigOption()`); + await this.ensureInitialized(); + const response = await this._connection.setSessionConfigOption(params); + if (Array.isArray((response as any)?.configOptions)) { + this._configOptions = [...(response as any).configOptions]; + } else if (this._configOptions) { + this._configOptions = this._configOptions.map((option: any) => { + const optionId = option?.id ?? option?.configId; + if (optionId !== params.configId) { + return option; + } + const next = { ...option }; + if (next.kind && typeof next.kind === 'object') { + next.kind = { ...next.kind, currentValue: params.value }; + } + next.currentValue = params.value; + return next; + }); + } + return response; + } + + 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(); + const response = await this._connection.unstable_setSessionModel(params); + this._currentModelId = (params as any).model ?? params.modelId; + return response; + } + + // ----------------------------------------------------------------------- + // 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._sessionNotifications = []; + this._sessionId = ''; + this._needsReset = false; + this.clearSessionState(); + // 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 { + if (!this.isCurrentSessionNotification(params)) { + return; + } + + 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': { + if (Array.isArray((update as any).availableCommands)) { + this._availableCommands = [...(update as any).availableCommands]; + } + break; + } + case 'plan': { + this.updatePlanEntry(update); + break; + } + case 'usage_update': { + this._usage = this.omitSessionUpdate(update); + break; + } + case 'current_mode_update': { + this._currentModeId = (update as any).currentModeId; + break; + } + case 'config_option_update': { + if (Array.isArray((update as any).configOptions)) { + this._configOptions = [...(update as any).configOptions]; + } + break; + } + case 'session_info_update': { + this._sessionInfo = { + ...(this._sessionInfo || {}), + ...(this.omitSessionUpdate(update) as AcpSessionInfoState), + }; + break; + } + default: + this.logger?.debug( + `[AcpThread:${this.threadId}] Unknown session update: ${ + (update as { sessionUpdate?: unknown }).sessionUpdate + }`, + ); + } + } + + private isCurrentSessionNotification(params: SessionNotification): boolean { + if (!params.sessionId || !this._sessionId || params.sessionId === this._sessionId) { + return true; + } + + this.logger?.warn( + `[AcpThread:${this.threadId}] Ignoring session notification for ${params.sessionId}; current session is ${this._sessionId}`, + ); + return false; + } + + 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.rawInput, + 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.rawInput !== undefined) { + entry.toolCall.rawInput = update.rawInput; + } + + 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 || (Array.isArray(update.entries) ? { entries: update.entries } : undefined)) as + | Plan + | undefined; + 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; + } + + private recordSessionNotification(notification: SessionNotification): void { + this._sessionNotifications.push(this.cloneSessionNotification(notification)); + } + + private cloneSessionNotification(notification: SessionNotification): SessionNotification { + return this.cloneJson(notification); + } + + private cloneJson(value: T): T { + if (value === undefined || value === null) { + return value; + } + const structuredCloneFn = (globalThis as any).structuredClone; + if (typeof structuredCloneFn === 'function') { + return structuredCloneFn(value); + } + return JSON.parse(JSON.stringify(value)); + } + + private recordDebugLog(direction: AcpDebugLogDirection, chunk: Uint8Array | Buffer | string): void { + if (direction === 'system') { + acpDebugLogStore.record({ + direction, + agentId: this.options.agentId, + threadId: this.threadId, + sessionId: this._sessionId || undefined, + raw: typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8'), + }); + return; + } + + if (direction === 'stderr') { + const raw = typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8'); + raw + .split(/\r?\n/) + .filter(Boolean) + .forEach((line) => + acpDebugLogStore.record({ + direction, + agentId: this.options.agentId, + threadId: this.threadId, + sessionId: this._sessionId || undefined, + raw: line, + }), + ); + return; + } + + let recorder = this._debugLogRecorders.get(direction); + if (!recorder) { + recorder = acpDebugLogStore.createLineRecorder({ + direction, + agentId: this.options.agentId, + threadId: this.threadId, + sessionId: this._sessionId || undefined, + }); + this._debugLogRecorders.set(direction, recorder); + } + recorder(chunk); + } + + private applySessionInitialState( + response: { modes?: any; configOptions?: unknown[] | null; models?: any } | null, + ): void { + if (!response) { + return; + } + this.applyModeState(response.modes); + this.applyModelState(response.models); + if (Array.isArray(response.configOptions)) { + this._configOptions = [...response.configOptions]; + } + } + + private applyModeState(modes: any): void { + if (!modes) { + return; + } + if (Array.isArray(modes.availableModes)) { + this._modes = [...modes.availableModes]; + } + if (typeof modes.currentModeId === 'string') { + this._currentModeId = modes.currentModeId; + } + } + + private applyModelState(models: any): void { + if (!models) { + return; + } + if (Array.isArray(models.availableModels)) { + this._models = [...models.availableModels]; + } + if (typeof models.currentModelId === 'string') { + this._currentModelId = models.currentModelId; + } + } + + private omitSessionUpdate(update: unknown): Record { + const { sessionUpdate, ...rest } = (update || {}) as Record; + return rest; + } + + private clearSessionState(): void { + this._modes = undefined; + this._currentModeId = undefined; + this._models = undefined; + this._currentModelId = undefined; + this._configOptions = undefined; + this._usage = undefined; + this._sessionInfo = undefined; + this._availableCommands = undefined; + } + + // ----------------------------------------------------------------------- + // Internal — permission request handling + // ----------------------------------------------------------------------- + private async handlePermissionRequest(params: RequestPermissionRequest): Promise { + const sessionId = params.sessionId || this._sessionId; + const toolCallId = params.toolCall.toolCallId; + const requestId = `${sessionId}:${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, toolCallId); + }); + } + + private async forwardPermissionRequest( + params: RequestPermissionRequest, + requestId: string, + toolCallId: 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) { + this._pendingPermissionRequests.delete(requestId); + pending.resolve(response); + } + this.respondToToolCall(toolCallId, 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..4f2d179595 --- /dev/null +++ b/packages/ai-native/src/node/acp/acp-update-types.ts @@ -0,0 +1,36 @@ +/** + * Agent update types — legacy stream format used by AcpAgentService + * and compatibility adapters. + */ + +import type { ThreadStatus } from './acp-thread'; + +export type AgentUpdateType = + | 'thought' + | 'message' + | 'tool_call' + | 'tool_call_args' + | 'tool_call_status' + | 'tool_result' + | 'plan' + | 'done' + | 'thread_status' + | 'session_state'; + +export interface SimpleToolCall { + toolCallId: string; + name: string; + input?: unknown; + status?: 'pending' | 'in_progress' | 'completed' | 'failed'; +} + +export interface AgentUpdate { + type: AgentUpdateType; + content: string; + toolCall?: SimpleToolCall; + threadStatus?: ThreadStatus; + sessionId?: string; + currentModeId?: string; + currentModelId?: string; + configOptions?: Record[]; +} 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..b4ad551f04 --- /dev/null +++ b/packages/ai-native/src/node/acp/acp-webmcp-caller.service.ts @@ -0,0 +1,54 @@ +import { Autowired, Injectable } from '@opensumi/di'; +import { RPCService } from '@opensumi/ide-connection'; + +import { AcpBrowserRpcRegistry } from './acp-browser-rpc-registry'; + +import type { + IAcpWebMcpBridgeService, + WebMcpGroupDef, + WebMcpToolResult, +} from '@opensumi/ide-core-common/lib/types/ai-native/acp-types'; + +interface WebMcpGroupDefinitionOptions { + includeAllTools?: boolean; +} + +/** + * Node-side RPC caller service for WebMCP bridge calls. + * Calls browser-side methods via RPC to retrieve group definitions and execute tools. + * + * Uses AcpBrowserRpcRegistry to bridge parent-injector consumers to the active + * per-connection browser RPC service without changing core connection wiring. + */ +@Injectable() +export class AcpWebMcpCallerService extends RPCService { + @Autowired(AcpBrowserRpcRegistry) + private readonly browserRpcRegistry: AcpBrowserRpcRegistry; + + private getRpcClient(clientId?: string): IAcpWebMcpBridgeService | undefined { + return this.client ?? this.browserRpcRegistry?.getWebMcpClient(clientId); + } + + async getGroupDefinitions(options?: WebMcpGroupDefinitionOptions, clientId?: string): Promise { + const rpcClient = this.getRpcClient(clientId); + if (!rpcClient) { + throw new Error('[AcpWebMcpCallerService] RPC client not available — browser connection not established'); + } + return (rpcClient.$getGroupDefinitions as (options?: WebMcpGroupDefinitionOptions) => Promise)( + options, + ); + } + + async executeTool( + group: string, + tool: string, + params: Record, + clientId?: string, + ): Promise { + const rpcClient = this.getRpcClient(clientId); + 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/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..336145f3ba 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,11 @@ export const AcpAgentRequestHandlerToken = Symbol('AcpAgentRequestHandlerToken') * ### Injector 层级问题 * * 由于 `AcpAgentRequestHandler` 在主 Injector 中创建,它通过 `@Autowired` 注入的 - * `AcpPermissionCallerManager` 不是 childInjector 中与 RPC 连接关联的实例。 + * `AcpPermissionCallerService` 不是 childInjector 中与 RPC 连接关联的实例。 * - * 解决方案:`AcpPermissionCallerManager` 使用静态变量 `currentRpcClient` 共享 RPC client, - * 确保权限对话框在用户当前活跃的 Browser Tab 中显示。 + * 解决方案:per-connection 的 ACP permission bridge 在连接建立时登记 Browser RPC client, + * `AcpPermissionCallerService` 再从 registry 取当前活跃的 client 调用 Browser 端 + * `AcpPermissionRpcService`。 * * @see {@link /docs/ai-native/architecture/injector-hierarchy.md} 详细设计文档 */ @@ -69,8 +70,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 +102,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 +150,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 +208,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 +271,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 +294,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 +316,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 +335,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..9f5b5e4f75 100644 --- a/packages/ai-native/src/node/acp/index.ts +++ b/packages/ai-native/src/node/acp/index.ts @@ -1,12 +1,46 @@ -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 { AcpBrowserRpcRegistry } from './acp-browser-rpc-registry'; +export { + AcpPermissionRpcBridgeService, + AcpPermissionRpcBridgeServiceToken, + AcpThreadStatusRpcBridgeService, + AcpThreadStatusRpcBridgeServiceToken, + AcpWebMcpRpcBridgeService, + AcpWebMcpRpcBridgeServiceToken, +} from './acp-browser-rpc-bridge.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, + AcpSessionInfoState, + AcpSessionState, + AcpThreadEvent, + AcpThreadOptions, + AcpThreadFactory, + AcpThreadFactoryToken, + AcpThreadFactoryProvider, + AcpThreadRuntimeConfig, +} from './acp-thread'; +export { AcpWebMcpCallerService } from './acp-webmcp-caller.service'; +export { OpenSumiMcpHttpServer } from './opensumi-mcp-http-server'; diff --git a/packages/ai-native/src/node/acp/opensumi-mcp-http-server.ts b/packages/ai-native/src/node/acp/opensumi-mcp-http-server.ts new file mode 100644 index 0000000000..901270d41b --- /dev/null +++ b/packages/ai-native/src/node/acp/opensumi-mcp-http-server.ts @@ -0,0 +1,1008 @@ +import { randomBytes, randomUUID } from 'node:crypto'; +import * as http from 'node:http'; + +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; + +import { Autowired, Injectable } from '@opensumi/di'; +import { ILogger } from '@opensumi/ide-core-common'; +import { AcpWebMcpCallerServiceToken } from '@opensumi/ide-core-common/lib/types/ai-native/acp-types'; +import { INodeLogger } from '@opensumi/ide-core-node'; + +import { + type WebMcpProfile, + type WebMcpToolRiskLevel, + canExposeWebMcpTool, + isWebMcpToolInProfile, +} from '../../common/webmcp-policy'; + +import type { AcpWebMcpCallerService } from './acp-webmcp-caller.service'; +import type { + OpenSumiMcpServerConnectionInfo, + WebMcpGroupDef, + WebMcpToolDef, +} from '@opensumi/ide-core-common/lib/types/ai-native/acp-types'; + +const OPEN_SUMI_MCP_SERVER_NAME = 'opensumi-ide'; +const LOOPBACK_HOST = '127.0.0.1'; +const MCP_PATH_PREFIX = '/mcp/'; +const MCP_CLIENT_ID_QUERY_PARAM = 'clientId'; +const CATALOG_TOOL_NAMES = { + discoverCapabilities: 'opensumi_discover_capabilities', + describeCapabilityGroup: 'opensumi_describe_capability_group', + describeTool: 'opensumi_describe_tool', + enableCapabilityGroup: 'opensumi_enable_capability_group', + invokeCapabilityTool: 'opensumi_invoke_capability_tool', +} as const; + +const LEGACY_CATALOG_TOOL_ALIASES: Record = { + opensumi_discoverCapabilities: CATALOG_TOOL_NAMES.discoverCapabilities, + opensumi_describeCapabilityGroup: CATALOG_TOOL_NAMES.describeCapabilityGroup, + opensumi_describeTool: CATALOG_TOOL_NAMES.describeTool, + opensumi_enableCapabilityGroup: CATALOG_TOOL_NAMES.enableCapabilityGroup, + opensumi_invokeCapabilityTool: CATALOG_TOOL_NAMES.invokeCapabilityTool, +}; + +type ExposableWebMcpToolDef = WebMcpGroupDef['tools'][number] & { + exposedByDefault?: boolean; +}; + +type WebMcpToolDefWithMeta = WebMcpToolDef & { + riskLevel?: WebMcpToolRiskLevel; + exposedByDefault?: boolean; + profiles?: WebMcpProfile[]; +}; + +type WebMcpGroupDefWithMeta = Omit & { + profile?: WebMcpProfile; + tools: WebMcpToolDefWithMeta[]; +}; + +interface WebMcpSessionState { + sessionId?: string; + browserClientId?: string; + enabledGroups: Set; +} + +interface ResolvedWebMcpTool { + group: WebMcpGroupDefWithMeta; + tool: WebMcpToolDefWithMeta; + name: string; +} + +type NormalizedInvokeCapabilityToolArgs = + | { + ok: true; + toolName: string; + toolArgs: Record; + } + | { + ok: false; + response: Record; + }; + +const CATALOG_GROUP_NAME = 'opensumi'; + +@Injectable() +export class OpenSumiMcpHttpServer { + @Autowired(AcpWebMcpCallerServiceToken) + private readonly caller: AcpWebMcpCallerService; + + @Autowired(INodeLogger) + private readonly logger: ILogger; + + private httpServer?: http.Server; + private readonly transports = new Map(); + private readonly token = randomBytes(16).toString('hex'); + private port = 0; + + async start(): Promise { + if (this.httpServer) { + return; + } + + this.httpServer = http.createServer((req, res) => { + this.handleRequest(req, res).catch((err) => { + this.logger?.error?.('[OpenSumiMcpHttpServer] Unhandled request error:', err); + if (!res.headersSent) { + res.writeHead(500).end(this.toErrorPayload(err)); + } else { + res.end(); + } + }); + }); + + await new Promise((resolve, reject) => { + this.httpServer!.once('error', reject); + this.httpServer!.listen(0, LOOPBACK_HOST, () => { + this.httpServer!.off('error', reject); + const address = this.httpServer!.address(); + if (!address || typeof address === 'string') { + reject(new Error('[OpenSumiMcpHttpServer] Failed to determine listening port')); + return; + } + this.port = address.port; + this.logger?.log?.(`[OpenSumiMcpHttpServer] Listening on ${this.getRedactedUrl()}`); + resolve(); + }); + }); + } + + getServerName(): string { + return OPEN_SUMI_MCP_SERVER_NAME; + } + + getUrl(browserClientId?: string): string { + if (!this.port) { + throw new Error('[OpenSumiMcpHttpServer] Server is not started'); + } + const url = new URL(`http://${LOOPBACK_HOST}:${this.port}${MCP_PATH_PREFIX}${this.token}`); + if (browserClientId) { + url.searchParams.set(MCP_CLIENT_ID_QUERY_PARAM, browserClientId); + } + return url.toString(); + } + + getConnectionInfo(browserClientId?: string): OpenSumiMcpServerConnectionInfo { + return { + name: this.getServerName(), + type: 'http', + transport: 'streamable-http', + url: this.getUrl(browserClientId), + redactedUrl: this.getRedactedUrl(browserClientId), + headers: [], + }; + } + + private getRedactedUrl(browserClientId?: string): string { + if (!this.port) { + throw new Error('[OpenSumiMcpHttpServer] Server is not started'); + } + const redactedUrl = `http://${LOOPBACK_HOST}:${this.port}${MCP_PATH_PREFIX}`; + return browserClientId + ? `${redactedUrl}?${MCP_CLIENT_ID_QUERY_PARAM}=${encodeURIComponent('')}` + : redactedUrl; + } + + async dispose(): Promise { + await Promise.all(Array.from(this.transports.values()).map((transport) => transport.close())); + this.transports.clear(); + + const server = this.httpServer; + this.httpServer = undefined; + this.port = 0; + + if (!server) { + return; + } + + await new Promise((resolve, reject) => { + server.close((err) => (err ? reject(err) : resolve())); + }); + } + + private createMcpServer(sessionState: WebMcpSessionState): Server { + const server = new Server( + { + name: OPEN_SUMI_MCP_SERVER_NAME, + version: '1.0.0', + }, + { + capabilities: { + tools: {}, + }, + }, + ); + + server.setRequestHandler(ListToolsRequestSchema, async () => { + const groupDefs = (await this.caller.getGroupDefinitions( + { + includeAllTools: true, + }, + sessionState.browserClientId, + )) as WebMcpGroupDefWithMeta[]; + const exposedGroupDefs = this.getExposedGroupDefs(groupDefs, sessionState); + const toolCount = groupDefs.reduce((count, group) => count + group.tools.length, 0); + const exposedToolCount = exposedGroupDefs.reduce((count, group) => count + group.tools.length, 0); + const toolStats = this.getToolDefinitionStats(exposedGroupDefs); + const profileGroup = groupDefs.find((group) => (group as { profile?: string }).profile) as + | { profile?: string } + | undefined; + const profile = profileGroup?.profile ?? 'unknown'; + this.logger?.log?.( + `[OpenSumiMcpHttpServer] tools/list — profile=${profile}, groups=${groupDefs.length}, tools=${toolCount}, exposedTools=${exposedToolCount}, schemaBytes=${toolStats.totalSchemaBytes}, descriptionBytes=${toolStats.totalDescriptionBytes}, totalToolBytes=${toolStats.totalToolBytes}`, + ); + this.logger?.log?.( + `[OpenSumiMcpHttpServer] tools/list group bytes — ${toolStats.groups + .map( + (group) => + `${group.name}:tools=${group.toolCount},schemaBytes=${group.schemaBytes},descriptionBytes=${group.descriptionBytes},totalToolBytes=${group.totalToolBytes}`, + ) + .join('; ')}`, + ); + this.logger?.log?.( + `[OpenSumiMcpHttpServer] tools/list largest tools — ${toolStats.largest + .map( + (tool) => + `${tool.name}:schemaBytes=${tool.schemaBytes},descriptionBytes=${tool.descriptionBytes},totalToolBytes=${tool.totalToolBytes}`, + ) + .join('; ')}`, + ); + return { + tools: exposedGroupDefs.flatMap((group) => + group.tools.map((tool) => ({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + })), + ), + }; + }); + + server.setRequestHandler(CallToolRequestSchema, async (request) => { + try { + const groupDefs = (await this.caller.getGroupDefinitions( + { + includeAllTools: true, + }, + sessionState.browserClientId, + )) as WebMcpGroupDefWithMeta[]; + const catalogResult = await this.handleCatalogTool( + groupDefs, + sessionState, + request.params.name, + (request.params.arguments ?? {}) as Record, + ); + if (catalogResult) { + return catalogResult; + } + + const target = this.resolveTool(this.getExposedGroupDefs(groupDefs, sessionState), request.params.name); + if (!target) { + return { + content: [{ type: 'text', text: `Tool not found: ${request.params.name}` }], + isError: true, + }; + } + + const result = await this.caller.executeTool( + target.group.name, + target.name, + (request.params.arguments ?? {}) as Record, + sessionState.browserClientId, + ); + this.logger?.log?.( + `[OpenSumiMcpHttpServer] tools/call — tool=${request.params.name}, group=${target.group.name}, riskLevel=${ + target.tool.riskLevel ?? 'unknown' + }, success=${result.success}`, + ); + return { + content: [{ type: 'text', text: JSON.stringify(result) }], + isError: !result.success, + }; + } catch (err) { + this.logger?.error?.(`[OpenSumiMcpHttpServer] Tool call failed: ${request.params.name}`, err); + return { + content: [{ type: 'text', text: this.toErrorMessage(err) }], + isError: true, + }; + } + }); + + return server; + } + + private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise { + if (!this.isAllowedRequest(req)) { + res.writeHead(404).end(); + return; + } + + let transport = this.getTransport(req); + if (!transport && this.getSessionId(req)) { + res.writeHead(404).end(); + return; + } + const createdTransport = !transport; + if (!transport) { + transport = await this.createTransport(this.getBrowserClientId(req)); + } + + await transport.handleRequest(req, res); + if (createdTransport && !transport.sessionId) { + await transport.close(); + } + + const sessionId = this.getSessionId(req); + if (req.method === 'DELETE' && sessionId) { + this.transports.delete(sessionId); + } + } + + private getTransport(req: http.IncomingMessage): StreamableHTTPServerTransport | undefined { + const sessionId = this.getSessionId(req); + if (!sessionId) { + return undefined; + } + return this.transports.get(sessionId); + } + + private async createTransport(browserClientId?: string): Promise { + let transport: StreamableHTTPServerTransport; + const sessionState: WebMcpSessionState = { + browserClientId, + enabledGroups: new Set(), + }; + transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + enableJsonResponse: true, + onsessioninitialized: (sessionId) => { + sessionState.sessionId = sessionId; + this.transports.set(sessionId, transport); + this.logger?.log?.(`[OpenSumiMcpHttpServer] session initialized — sessionId=${sessionId}`); + }, + }); + await this.createMcpServer(sessionState).connect(transport); + return transport; + } + + private getSessionId(req: http.IncomingMessage): string | undefined { + const sessionId = req.headers['mcp-session-id']; + return typeof sessionId === 'string' ? sessionId : undefined; + } + + private getBrowserClientId(req: http.IncomingMessage): string | undefined { + const url = new URL(req.url ?? '/', `http://${LOOPBACK_HOST}`); + const clientId = url.searchParams.get(MCP_CLIENT_ID_QUERY_PARAM); + return clientId || undefined; + } + + private isAllowedRequest(req: http.IncomingMessage): boolean { + if (!this.isAllowedHost(req.headers.host)) { + return false; + } + + const url = new URL(req.url ?? '/', `http://${LOOPBACK_HOST}`); + return url.pathname === `${MCP_PATH_PREFIX}${this.token}`; + } + + private isAllowedHost(host?: string): boolean { + return !host || host.startsWith(`${LOOPBACK_HOST}:`) || host.startsWith('localhost:'); + } + + private resolveTool(groupDefs: WebMcpGroupDefWithMeta[], toolName: string): ResolvedWebMcpTool | undefined { + for (const group of groupDefs) { + for (const tool of group.tools) { + if (tool.name === toolName) { + return { group, tool, name: toolName }; + } + } + } + return undefined; + } + + private getExposedGroupDefs( + groupDefs: WebMcpGroupDefWithMeta[], + sessionState: WebMcpSessionState, + ): WebMcpGroupDefWithMeta[] { + const exposed = groupDefs + .map((group) => { + const enabled = sessionState.enabledGroups.has(group.name); + return { + ...group, + defaultLoaded: group.defaultLoaded || enabled, + tools: group.tools.filter((tool) => this.isToolExposed(group, tool, enabled)), + }; + }) + .filter((group) => group.tools.length > 0); + + return [this.getCatalogGroupDef(), ...exposed]; + } + + private isToolExposed(group: WebMcpGroupDefWithMeta, tool: WebMcpToolDefWithMeta, groupEnabled: boolean): boolean { + // This is an MCP visibility rule, not a full authorization layer. The + // catalog starts small, then enableCapabilityGroup expands the current MCP + // session's tool surface. Concrete tools still own permission prompts and + // business-specific safety checks at execution time. + if ((tool as ExposableWebMcpToolDef).exposedByDefault === false) { + return false; + } + + const profile = group.profile ?? 'default'; + if (groupEnabled) { + return this.isToolAllowedAfterEnable(tool, profile); + } + + return group.defaultLoaded && this.isToolInDefaultProfile(tool, profile); + } + + private isToolAllowedAfterEnable(tool: WebMcpToolDefWithMeta, profile: WebMcpProfile): boolean { + return canExposeWebMcpTool(tool, profile); + } + + private isToolInDefaultProfile(tool: WebMcpToolDefWithMeta, profile: WebMcpProfile): boolean { + return isWebMcpToolInProfile(tool, profile); + } + + private getCatalogGroupDef(): WebMcpGroupDefWithMeta { + return { + name: CATALOG_GROUP_NAME, + description: + 'Discover and enable additional OpenSumi IDE WebMCP capability groups when the current tool list is too small.', + defaultLoaded: true, + tools: [ + { + name: CATALOG_TOOL_NAMES.discoverCapabilities, + description: + 'Discover hidden OpenSumi IDE capability groups. Call this when you need search, file read, language navigation, SCM, debug, tasks, output logs, ACP chat state, permissions, or terminal interaction tools that are not currently listed.', + riskLevel: 'read', + inputSchema: { + type: 'object', + properties: { + task: { + type: 'string', + description: 'Short description of the current user task. Do not include secrets or file contents.', + }, + includeDisabled: { + type: 'boolean', + description: 'Include groups that currently have no available tools.', + }, + }, + additionalProperties: false, + }, + }, + { + name: CATALOG_TOOL_NAMES.describeCapabilityGroup, + description: + 'Describe one OpenSumi capability group and its tools. Use includeSchemas only when you need exact parameters.', + riskLevel: 'read', + inputSchema: { + type: 'object', + properties: { + group: { + type: 'string', + description: + 'Capability group name, for example search, file, terminal, editor, diagnostics, workspace, or acp_chat.', + }, + includeSchemas: { + type: 'boolean', + description: 'Return full input schemas for every tool in the group.', + }, + }, + required: ['group'], + additionalProperties: false, + }, + }, + { + name: CATALOG_TOOL_NAMES.describeTool, + description: 'Return one OpenSumi WebMCP tool description and full input schema.', + riskLevel: 'read', + inputSchema: { + type: 'object', + properties: { + tool: { + type: 'string', + description: 'MCP tool name such as search_text.', + }, + }, + required: ['tool'], + additionalProperties: false, + }, + }, + { + name: CATALOG_TOOL_NAMES.enableCapabilityGroup, + description: + 'Enable an OpenSumi capability group for this MCP session. This only changes tool visibility; it does not execute IDE actions.', + riskLevel: 'read', + inputSchema: { + type: 'object', + properties: { + group: { + type: 'string', + description: 'Capability group name to enable.', + }, + }, + required: ['group'], + additionalProperties: false, + }, + }, + { + name: CATALOG_TOOL_NAMES.invokeCapabilityTool, + description: + 'Fallback broker for calling an enabled OpenSumi capability tool when the MCP client does not refresh tools/list after enabling a group.', + riskLevel: 'read', + inputSchema: { + type: 'object', + properties: { + tool: { + type: 'string', + description: 'MCP tool name such as search_text.', + }, + arguments: { + type: 'object', + description: 'Arguments for the target tool.', + additionalProperties: true, + }, + }, + required: ['tool'], + additionalProperties: false, + }, + }, + ], + }; + } + + private async handleCatalogTool( + groupDefs: WebMcpGroupDefWithMeta[], + sessionState: WebMcpSessionState, + toolName: string, + args: Record, + ): Promise<{ content: Array<{ type: 'text'; text: string }>; isError: boolean } | undefined> { + const normalizedToolName = LEGACY_CATALOG_TOOL_ALIASES[toolName] ?? toolName; + switch (normalizedToolName) { + case CATALOG_TOOL_NAMES.discoverCapabilities: + return this.toToolResponse(this.discoverCapabilities(groupDefs, sessionState, args)); + case CATALOG_TOOL_NAMES.describeCapabilityGroup: + return this.toToolResponse(this.describeCapabilityGroup(groupDefs, args)); + case CATALOG_TOOL_NAMES.describeTool: + return this.toToolResponse(this.describeTool(groupDefs, args)); + case CATALOG_TOOL_NAMES.enableCapabilityGroup: + return this.toToolResponse(this.enableCapabilityGroup(groupDefs, sessionState, args)); + case CATALOG_TOOL_NAMES.invokeCapabilityTool: + return this.invokeCapabilityTool(groupDefs, sessionState, args); + default: + return undefined; + } + } + + private discoverCapabilities( + groupDefs: WebMcpGroupDefWithMeta[], + sessionState: WebMcpSessionState, + args: Record, + ): Record { + const task = typeof args.task === 'string' ? args.task : ''; + const includeDisabled = args.includeDisabled === true; + const recommended = this.getRecommendedGroups(groupDefs, task) + .filter((groupName) => { + const group = groupDefs.find((item) => item.name === groupName); + return group ? this.getToolsAvailableAfterEnable(group).length > 0 : false; + }) + .filter((group) => !sessionState.enabledGroups.has(group)) + .map((group) => ({ + group, + reason: this.getRecommendationReason(group), + nextAction: CATALOG_TOOL_NAMES.enableCapabilityGroup, + arguments: { group }, + })); + const groups = groupDefs + .map((group) => { + const explicitlyEnabled = sessionState.enabledGroups.has(group.name); + const currentTools = this.getCurrentlyExposedTools(group, sessionState); + const toolsAfterEnable = this.getToolsAvailableAfterEnable(group); + const defaultTools = this.getDefaultExposedTools(group); + return { + name: group.name, + summary: group.description, + whenToUse: this.getGroupWhenToUse(group.name), + risk: this.getGroupRisk(toolsAfterEnable), + profile: group.profile ?? 'default', + enabled: explicitlyEnabled, + defaultExposed: defaultTools.length > 0, + status: explicitlyEnabled ? 'enabled' : defaultTools.length > 0 ? 'default' : 'available', + currentlyAvailableToolCount: currentTools.length, + defaultToolCount: defaultTools.length, + availableAfterEnableToolCount: toolsAfterEnable.length, + toolCount: currentTools.length, + estimatedBytes: this.getGroupToolBytes(currentTools), + }; + }) + .filter( + (group) => includeDisabled || group.currentlyAvailableToolCount > 0 || group.availableAfterEnableToolCount > 0, + ); + + this.logger?.log?.( + `[OpenSumiMcpHttpServer] capabilities/discover — sessionId=${sessionState.sessionId ?? 'unknown'}, taskChars=${ + task.length + }, recommendedGroups=${recommended.map((item) => item.group).join(',')}, groupCount=${groups.length}`, + ); + return { success: true, result: { recommended, groups } }; + } + + private describeCapabilityGroup( + groupDefs: WebMcpGroupDefWithMeta[], + args: Record, + ): Record { + const groupName = typeof args.group === 'string' ? args.group : ''; + const includeSchemas = args.includeSchemas === true; + const group = [this.getCatalogGroupDef(), ...groupDefs].find((item) => item.name === groupName); + if (!group) { + return { success: false, error: 'GROUP_NOT_FOUND', details: `Group "${groupName}" not found` }; + } + + const tools = this.getToolsAvailableAfterEnable(group).map((tool) => ({ + name: tool.name, + description: tool.description, + riskLevel: tool.riskLevel ?? 'read', + ...(includeSchemas + ? { inputSchema: tool.inputSchema } + : { inputSummary: this.summarizeInputSchema(tool.inputSchema) }), + })); + const schemaBytes = includeSchemas + ? this.getJsonByteLength(tools.map((tool) => (tool as { inputSchema?: unknown }).inputSchema)) + : 0; + this.logger?.log?.( + `[OpenSumiMcpHttpServer] capabilities/describeGroup — group=${group.name}, includeSchemas=${includeSchemas}, schemaBytes=${schemaBytes}`, + ); + return { + success: true, + result: { + group: group.name, + summary: group.description, + whenToUse: this.getGroupWhenToUse(group.name), + toolCount: tools.length, + tools, + }, + }; + } + + private describeTool(groupDefs: WebMcpGroupDefWithMeta[], args: Record): Record { + const toolName = typeof args.tool === 'string' ? args.tool : ''; + const target = this.resolveAnyTool([this.getCatalogGroupDef(), ...groupDefs], toolName); + if (!target) { + return { success: false, error: 'TOOL_NOT_FOUND', details: `Tool "${toolName}" not found` }; + } + if (!this.isToolAllowedAfterEnable(target.tool, target.group.profile ?? 'default')) { + return { + success: false, + error: 'CAPABILITY_NOT_AVAILABLE', + details: `Tool "${toolName}" is not available in the current WebMCP profile`, + }; + } + + const schemaBytes = this.getJsonByteLength(target.tool.inputSchema); + this.logger?.log?.( + `[OpenSumiMcpHttpServer] capabilities/describeTool — tool=${target.name}, schemaBytes=${schemaBytes}`, + ); + return { + success: true, + result: { + name: target.name, + group: target.group.name, + description: target.tool.description, + riskLevel: target.tool.riskLevel ?? 'read', + inputSchema: target.tool.inputSchema, + }, + }; + } + + private enableCapabilityGroup( + groupDefs: WebMcpGroupDefWithMeta[], + sessionState: WebMcpSessionState, + args: Record, + ): Record { + const groupName = typeof args.group === 'string' ? args.group : ''; + const group = groupDefs.find((item) => item.name === groupName); + if (!group) { + return { success: false, error: 'GROUP_NOT_FOUND', details: `Group "${groupName}" not found` }; + } + sessionState.enabledGroups.add(group.name); + const firstTool = this.getToolsAvailableAfterEnable(group)[0]; + this.logger?.log?.( + `[OpenSumiMcpHttpServer] capabilities/enableGroup — sessionId=${sessionState.sessionId ?? 'unknown'}, group=${ + group.name + }, enabledGroups=${Array.from(sessionState.enabledGroups).join(',')}`, + ); + return { + success: true, + result: { + enabled: true, + group: group.name, + enabledGroups: Array.from(sessionState.enabledGroups), + refreshRequired: true, + fallbackTool: CATALOG_TOOL_NAMES.invokeCapabilityTool, + example: firstTool + ? { + tool: CATALOG_TOOL_NAMES.invokeCapabilityTool, + arguments: { + tool: firstTool.name, + arguments: {}, + }, + } + : undefined, + }, + }; + } + + private async invokeCapabilityTool( + groupDefs: WebMcpGroupDefWithMeta[], + sessionState: WebMcpSessionState, + args: Record, + ): Promise<{ content: Array<{ type: 'text'; text: string }>; isError: boolean }> { + const normalized = this.normalizeInvokeCapabilityToolArgs(args); + if (!normalized.ok) { + return this.toToolResponse(normalized.response); + } + + const { toolName, toolArgs } = normalized; + const target = this.resolveAnyTool(groupDefs, toolName); + if (!target) { + return this.toToolResponse({ success: false, error: 'TOOL_NOT_FOUND', details: `Tool "${toolName}" not found` }); + } + + const groupEnabled = sessionState.enabledGroups.has(target.group.name); + if (!this.isToolExposed(target.group, target.tool, groupEnabled)) { + return this.toToolResponse({ + success: false, + error: 'CAPABILITY_NOT_ENABLED', + details: `Enable group "${target.group.name}" with ${CATALOG_TOOL_NAMES.enableCapabilityGroup} before invoking "${target.name}".`, + }); + } + + const result = await this.caller.executeTool( + target.group.name, + target.name, + toolArgs, + sessionState.browserClientId, + ); + this.logger?.log?.( + `[OpenSumiMcpHttpServer] capabilities/invokeTool — tool=${target.name}, group=${target.group.name}, riskLevel=${ + target.tool.riskLevel ?? 'unknown' + }, success=${result.success}`, + ); + return this.toToolResponse(result as unknown as Record); + } + + private normalizeInvokeCapabilityToolArgs(args: Record): NormalizedInvokeCapabilityToolArgs { + const nested = this.asRecord(args.arguments); + const invocationArgs = typeof args.tool === 'string' ? args : this.isInvokeCapabilityArgs(nested) ? nested : args; + const toolName = typeof invocationArgs.tool === 'string' ? invocationArgs.tool : ''; + + if (!toolName) { + return { + ok: false, + response: { + success: false, + error: 'INVALID_ARGUMENTS', + details: `Invalid arguments for ${CATALOG_TOOL_NAMES.invokeCapabilityTool}. Expected { tool: string, arguments?: object }.`, + }, + }; + } + + const rawToolArgs = this.asRecord(invocationArgs.arguments); + const toolArgs = this.shouldUnwrapNestedArguments(rawToolArgs) ? this.asRecord(rawToolArgs.arguments) : rawToolArgs; + + return { + ok: true, + toolName, + toolArgs, + }; + } + + private isInvokeCapabilityArgs(value: Record): boolean { + return typeof value.tool === 'string' || Object.prototype.hasOwnProperty.call(value, 'arguments'); + } + + private shouldUnwrapNestedArguments(value: Record): boolean { + return ( + Object.keys(value).length === 1 && + Object.prototype.hasOwnProperty.call(value, 'arguments') && + this.isRecordValue(value.arguments) + ); + } + + private resolveAnyTool(groupDefs: WebMcpGroupDefWithMeta[], toolName: string): ResolvedWebMcpTool | undefined { + for (const group of groupDefs) { + for (const tool of group.tools) { + if (tool.name === toolName) { + return { group, tool, name: tool.name }; + } + } + } + return undefined; + } + + private getToolsAvailableAfterEnable(group: WebMcpGroupDefWithMeta): WebMcpToolDefWithMeta[] { + const profile = group.profile ?? 'default'; + return group.tools.filter( + (tool) => + (tool as ExposableWebMcpToolDef).exposedByDefault !== false && this.isToolAllowedAfterEnable(tool, profile), + ); + } + + private getCurrentlyExposedTools( + group: WebMcpGroupDefWithMeta, + sessionState: WebMcpSessionState, + ): WebMcpToolDefWithMeta[] { + const enabled = sessionState.enabledGroups.has(group.name); + return group.tools.filter((tool) => this.isToolExposed(group, tool, enabled)); + } + + private getDefaultExposedTools(group: WebMcpGroupDefWithMeta): WebMcpToolDefWithMeta[] { + if (!group.defaultLoaded) { + return []; + } + return group.tools.filter( + (tool) => + (tool as ExposableWebMcpToolDef).exposedByDefault !== false && + this.isToolInDefaultProfile(tool, group.profile ?? 'default'), + ); + } + + private getRecommendedGroups(groupDefs: WebMcpGroupDefWithMeta[], task: string): string[] { + const lowerTask = task.toLowerCase(); + const candidates: string[] = []; + const add = (group: string) => { + if (groupDefs.some((item) => item.name === group) && !candidates.includes(group)) { + candidates.push(group); + } + }; + + if (/search|find|grep|symbol|reference|查找|搜索|引用|符号/.test(lowerTask)) { + add('search'); + } + if (/file|path|read|stat|文件|路径|目录/.test(lowerTask)) { + add('file'); + } + if (/terminal|shell|command|process|进程|终端|命令|交互/.test(lowerTask)) { + add('terminal'); + } + if (/diagnostic|problem|error|warning|报错|问题|诊断/.test(lowerTask)) { + add('diagnostics'); + } + if (/editor|selection|buffer|dirty|diff|编辑器|选区|未保存/.test(lowerTask)) { + add('editor'); + } + if (/acp|chat|session|permission|agent status|聊天|会话|权限|许可|智能体状态/.test(lowerTask)) { + add('acp_chat'); + } + return candidates; + } + + private getRecommendationReason(group: string): string { + const reasons: Record = { + search: 'Task appears to need workspace-wide lookup or symbol discovery.', + file: 'Task appears to need IDE-side file metadata or file reads.', + terminal: 'Task appears to need observing or interacting with an IDE terminal.', + diagnostics: 'Task appears to need IDE diagnostics or problem navigation.', + editor: 'Task appears to need active editor, selection, dirty buffer, or diff context.', + acp_chat: 'Task appears to need ACP chat session state or permission status.', + }; + return reasons[group] ?? `Task may need the ${group} OpenSumi capability group.`; + } + + private getGroupWhenToUse(group: string): string { + const hints: Record = { + workspace: 'Use for current workspace roots, open files, and window context.', + search: 'Use when the exact file path, text location, or symbol location is unknown.', + diagnostics: 'Use when you need IDE/LSP problems, error stats, or to open a diagnostic.', + file: 'Use for IDE-side file reads and metadata when shell/filesystem context is insufficient.', + terminal: + 'Use to observe existing IDE terminals, read recent output, tail long-running processes, or interact when enabled by profile.', + editor: 'Use for active editor, selection, unsaved buffers, dirty diffs, and editor UI navigation.', + acp_chat: + 'Use for ACP chat session metadata, thread status, permission dialog counts, and showing the chat panel.', + }; + return hints[group] ?? `Use for OpenSumi ${group} IDE capability.`; + } + + private getGroupRisk(tools: WebMcpToolDefWithMeta[]): WebMcpToolRiskLevel { + const order: WebMcpToolRiskLevel[] = ['read', 'ui', 'write', 'shell', 'destructive']; + return tools.reduce((max, tool) => { + const risk = tool.riskLevel ?? 'read'; + return order.indexOf(risk) > order.indexOf(max) ? risk : max; + }, 'read'); + } + + private summarizeInputSchema(schema: Record): Record { + const properties = this.asRecord(schema.properties); + const required = Array.isArray(schema.required) ? schema.required.filter((item) => typeof item === 'string') : []; + return { + required, + properties: Object.entries(properties).map(([name, value]) => ({ + name, + type: this.asRecord(value).type ?? 'unknown', + })), + }; + } + + private getGroupToolBytes(tools: WebMcpToolDefWithMeta[]): number { + return tools.reduce( + (total, tool) => + total + + this.getJsonByteLength({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + }), + 0, + ); + } + + private toToolResponse(result: Record): { + content: Array<{ type: 'text'; text: string }>; + isError: boolean; + } { + return { + content: [{ type: 'text', text: JSON.stringify(result) }], + isError: result.success === false, + }; + } + + private asRecord(value: unknown): Record { + return this.isRecordValue(value) ? value : {}; + } + + private isRecordValue(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); + } + + private getToolDefinitionStats(groupDefs: WebMcpGroupDefWithMeta[]): { + totalSchemaBytes: number; + totalDescriptionBytes: number; + totalToolBytes: number; + groups: Array<{ + name: string; + toolCount: number; + schemaBytes: number; + descriptionBytes: number; + totalToolBytes: number; + }>; + largest: Array<{ name: string; schemaBytes: number; descriptionBytes: number; totalToolBytes: number }>; + } { + const largest: Array<{ name: string; schemaBytes: number; descriptionBytes: number; totalToolBytes: number }> = []; + const groups = groupDefs.map((group) => { + const stats = group.tools.reduce( + (total, tool) => { + const schemaBytes = this.getJsonByteLength(tool.inputSchema); + const descriptionBytes = this.getStringByteLength(tool.description); + const totalToolBytes = this.getJsonByteLength({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + }); + total.schemaBytes += schemaBytes; + total.descriptionBytes += descriptionBytes; + total.totalToolBytes += totalToolBytes; + largest.push({ + name: tool.name, + schemaBytes, + descriptionBytes, + totalToolBytes, + }); + return total; + }, + { schemaBytes: 0, descriptionBytes: 0, totalToolBytes: 0 }, + ); + return { + name: group.name, + toolCount: group.tools.length, + ...stats, + }; + }); + + return { + totalSchemaBytes: groups.reduce((total, group) => total + group.schemaBytes, 0), + totalDescriptionBytes: groups.reduce((total, group) => total + group.descriptionBytes, 0), + totalToolBytes: groups.reduce((total, group) => total + group.totalToolBytes, 0), + groups, + largest: largest.sort((a, b) => b.totalToolBytes - a.totalToolBytes).slice(0, 5), + }; + } + + private getStringByteLength(value: string): number { + return Buffer.byteLength(value, 'utf8'); + } + + private getJsonByteLength(value: unknown): number { + return Buffer.byteLength(JSON.stringify(value ?? null), 'utf8'); + } + + private toErrorPayload(err: unknown): string { + return JSON.stringify({ error: this.toErrorMessage(err) }); + } + + private toErrorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); + } +} 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..4b47ce121e --- /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, AcpPermissionCallerServiceToken } 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(AcpPermissionCallerServiceToken) + 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..802a60a7c9 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'; @@ -15,17 +17,28 @@ import { AcpAgentRequestHandlerToken, AcpAgentService, AcpAgentServiceToken, + AcpBrowserRpcRegistry, AcpFileSystemHandler, AcpFileSystemHandlerToken, - AcpPermissionCallerManager, - AcpPermissionCallerManagerToken, + AcpPermissionCallerService, + AcpPermissionCallerServiceToken, + AcpPermissionRpcBridgeService, + AcpPermissionRpcBridgeServiceToken, AcpTerminalHandler, AcpTerminalHandlerToken, - CliAgentProcessManager, - CliAgentProcessManagerToken, + AcpThreadFactoryProvider, + AcpThreadStatusCallerService, + AcpThreadStatusCallerServiceToken, + AcpThreadStatusRpcBridgeService, + AcpThreadStatusRpcBridgeServiceToken, + AcpWebMcpCallerService, + AcpWebMcpRpcBridgeService, + AcpWebMcpRpcBridgeServiceToken, + OpenSumiMcpHttpServer, + 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'; @@ -37,20 +50,25 @@ export class AINativeModule extends NodeModule { useClass: AcpCliBackService, }, { - token: AcpCliClientServiceToken, - useClass: AcpCliClientService, + token: AcpAgentServiceToken, + useClass: AcpAgentService, }, { - token: CliAgentProcessManagerToken, - useClass: CliAgentProcessManager, + token: AcpPermissionCallerServiceToken, + useClass: AcpPermissionCallerService, }, + AcpBrowserRpcRegistry, { - token: AcpAgentServiceToken, - useClass: AcpAgentService, + token: AcpPermissionRpcBridgeServiceToken, + useClass: AcpPermissionRpcBridgeService, }, { - token: AcpPermissionCallerManagerToken, - useClass: AcpPermissionCallerManager, + token: AcpThreadStatusRpcBridgeServiceToken, + useClass: AcpThreadStatusRpcBridgeService, + }, + { + token: AcpWebMcpRpcBridgeServiceToken, + useClass: AcpWebMcpRpcBridgeService, }, { token: ToolInvocationRegistryManager, @@ -72,6 +90,25 @@ 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, + }, + // Built-in HTTP MCP server for exposing WebMCP tools to ACP agents + OpenSumiMcpHttpServer, // Language models for non-ACP fallback OpenAICompatibleModel, ]; @@ -91,7 +128,15 @@ export class AINativeModule extends NodeModule { }, { servicePath: AcpPermissionServicePath, - token: AcpPermissionCallerManagerToken, + token: AcpPermissionRpcBridgeServiceToken, + }, + { + servicePath: AcpThreadStatusServicePath, + token: AcpThreadStatusRpcBridgeServiceToken, + }, + { + servicePath: AcpWebMcpBridgePath, + token: AcpWebMcpRpcBridgeServiceToken, }, ]; } diff --git a/packages/ai-native/src/node/mcp-log-utils.ts b/packages/ai-native/src/node/mcp-log-utils.ts new file mode 100644 index 0000000000..8485de14a7 --- /dev/null +++ b/packages/ai-native/src/node/mcp-log-utils.ts @@ -0,0 +1,44 @@ +const SENSITIVE_ENV_PATTERN = /(token|key|secret|password|authorization|credential)/i; + +export function summarizeMcpEnv(env?: Record): Record { + const envEntries = Object.entries(env ?? {}); + const path = env?.PATH ?? env?.Path ?? env?.path; + return { + keys: envEntries.map(([key]) => key).sort(), + sensitiveKeys: envEntries + .map(([key]) => key) + .filter((key) => SENSITIVE_ENV_PATTERN.test(key)) + .sort(), + pathEntries: path ? path.split(':').filter(Boolean).length : 0, + pathBytes: path ? Buffer.byteLength(path, 'utf8') : 0, + }; +} + +export function summarizeMcpTools(tools: any): Record { + const toolsArray = Array.isArray(tools?.tools) ? tools.tools : []; + const toolStats = toolsArray.map((tool) => { + const schemaBytes = Buffer.byteLength(JSON.stringify(tool.inputSchema ?? null), 'utf8'); + const descriptionBytes = Buffer.byteLength(tool.description ?? '', 'utf8'); + return { + name: tool.name, + schemaBytes, + descriptionBytes, + totalToolBytes: Buffer.byteLength( + JSON.stringify({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + }), + 'utf8', + ), + }; + }); + const largest = [...toolStats].sort((a, b) => b.totalToolBytes - a.totalToolBytes).slice(0, 5); + return { + toolCount: toolsArray.length, + schemaBytes: toolStats.reduce((total, tool) => total + tool.schemaBytes, 0), + descriptionBytes: toolStats.reduce((total, tool) => total + tool.descriptionBytes, 0), + totalToolBytes: toolStats.reduce((total, tool) => total + tool.totalToolBytes, 0), + largest, + }; +} diff --git a/packages/ai-native/src/node/mcp-server.sse.ts b/packages/ai-native/src/node/mcp-server.sse.ts index aa9117e707..9e8755cb5c 100644 --- a/packages/ai-native/src/node/mcp-server.sse.ts +++ b/packages/ai-native/src/node/mcp-server.sse.ts @@ -10,6 +10,8 @@ import { IMCPServer } from '../common/mcp-server-manager'; import { SSEClientTransportOptions } from '../common/types'; import { toClaudeToolName } from '../common/utils'; +import { summarizeMcpTools } from './mcp-log-utils'; + global.EventSource = EventSource as any; export class SSEMCPServer implements IMCPServer { private name: string; @@ -76,7 +78,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 +99,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 || []; @@ -114,8 +116,15 @@ export class SSEMCPServer implements IMCPServer { ...originalTools, tools: sanitizedToolsArray, }; - this.logger?.log(`Got tools from MCP server "${this.name}" with url "${this.url}":`, sanitizedTools); - this.logger?.log('Tool name mapping: ', Object.fromEntries(this.toolNameMap)); + this.logger?.log( + `Got tools from MCP server "${this.name}" with url "${this.url}": ${JSON.stringify({ + ...summarizeMcpTools(sanitizedTools), + renamedTools: this.toolNameMap.size, + })}`, + ); + if (this.toolNameMap.size > 0) { + this.logger?.debug?.('Tool name mapping: ', Object.fromEntries(this.toolNameMap)); + } return sanitizedTools; } diff --git a/packages/ai-native/src/node/mcp-server.stdio.ts b/packages/ai-native/src/node/mcp-server.stdio.ts index 4634f4f989..0aba64b89d 100644 --- a/packages/ai-native/src/node/mcp-server.stdio.ts +++ b/packages/ai-native/src/node/mcp-server.stdio.ts @@ -8,6 +8,8 @@ import pkg from '../../package.json'; import { IMCPServer } from '../common/mcp-server-manager'; import { toClaudeToolName } from '../common/utils'; +import { summarizeMcpEnv, summarizeMcpTools } from './mcp-log-utils'; + export class StdioMCPServer implements IMCPServer { private name: string; public command: string; @@ -50,9 +52,9 @@ export class StdioMCPServer implements IMCPServer { return; } this.logger?.log( - `Starting server "${this.name}" with command: ${this.command} and args: ${this.args?.join( - ' ', - )} and env: ${JSON.stringify(this.env)} and cwd: ${this.cwd}`, + `Starting server "${this.name}" with command: ${this.command}, args=${JSON.stringify(this.args ?? [])}, cwd=${ + this.cwd + }, envSummary=${JSON.stringify(summarizeMcpEnv(this.env))}`, ); // Filter process.env to exclude undefined values const sanitizedEnv: Record = Object.fromEntries( @@ -91,7 +93,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 +114,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 @@ -130,8 +132,15 @@ export class StdioMCPServer implements IMCPServer { ...originalTools, tools: sanitizedToolsArray, }; - this.logger?.log(`Got tools from MCP server "${this.name}":`, sanitizedTools); - this.logger?.log('Tool name mapping: ', Object.fromEntries(this.toolNameMap)); + this.logger?.log( + `Got tools from MCP server "${this.name}": ${JSON.stringify({ + ...summarizeMcpTools(sanitizedTools), + renamedTools: this.toolNameMap.size, + })}`, + ); + if (this.toolNameMap.size > 0) { + this.logger?.debug?.('Tool name mapping: ', Object.fromEntries(this.toolNameMap)); + } return sanitizedTools; } 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/__tests__/components/layout/default-layout.test.ts b/packages/core-browser/__tests__/components/layout/default-layout.test.ts new file mode 100644 index 0000000000..c8efb9ccc2 --- /dev/null +++ b/packages/core-browser/__tests__/components/layout/default-layout.test.ts @@ -0,0 +1,20 @@ +import { fixLayout } from '../../../src/components/layout/default-layout'; + +describe('default layout storage', () => { + it('should remove legacy undefined layout entries', () => { + expect( + fixLayout({ + undefined: {}, + view: { + currentId: 'explorer', + size: 310, + }, + }), + ).toEqual({ + view: { + currentId: 'explorer', + size: 310, + }, + }); + }); +}); diff --git a/packages/core-browser/src/ai-native/command.ts b/packages/core-browser/src/ai-native/command.ts index 0bf42bac46..1df8dc0f2a 100644 --- a/packages/core-browser/src/ai-native/command.ts +++ b/packages/core-browser/src/ai-native/command.ts @@ -26,6 +26,14 @@ export const AI_CHAT_VISIBLE = { id: 'ai.chat.visible', }; +export const AI_PANEL_LAYOUT_SET = { + id: 'ai-native.panel-layout.set', +}; + +export const AI_PANEL_LAYOUT_TOGGLE = { + id: 'ai-native.panel-layout.toggle', +}; + export const AI_CODE_ACTION = { id: 'ai.code.action', }; diff --git a/packages/core-browser/src/components/layout/default-layout.tsx b/packages/core-browser/src/components/layout/default-layout.tsx index dd9d9ebc9a..8c27995ca2 100644 --- a/packages/core-browser/src/components/layout/default-layout.tsx +++ b/packages/core-browser/src/components/layout/default-layout.tsx @@ -9,12 +9,12 @@ export interface ILayoutConfigCache { [key: string]: { size?: number; currentId?: string }; } -export const getStorageValue = () => { +export const getStorageValue = (layoutStorageKey = 'layout') => { // 启动时渲染的颜色和尺寸,弱依赖 let savedLayout: ILayoutConfigCache = {}; let savedColors: { [colorKey: string]: string } = {}; try { - const layoutConfigStr = localStorage.getItem('layout'); + const layoutConfigStr = localStorage.getItem(layoutStorageKey); if (layoutConfigStr) { savedLayout = JSON.parse(layoutConfigStr); } @@ -83,6 +83,15 @@ export function ToolbarActionBasedLayout( export function fixLayout(layout: ILayoutConfigCache) { const newLayout = { ...layout }; for (const key in layout) { + if (!Object.prototype.hasOwnProperty.call(layout, key)) { + continue; + } + + if (key === 'undefined') { + delete newLayout[key]; + continue; + } + if (!layout[key] || key === 'containerLocations') { continue; } diff --git a/packages/core-browser/src/layout/constants.ts b/packages/core-browser/src/layout/constants.ts index 04196ddd46..8ca45f6418 100644 --- a/packages/core-browser/src/layout/constants.ts +++ b/packages/core-browser/src/layout/constants.ts @@ -158,6 +158,7 @@ export class DesignLayoutConfig implements IDesignLayoutConfig { useMenubarView: false, menubarLogo: '', supportExternalChatPanel: false, + panelLayout: 'classic', }; setLayout(...value: (Partial | undefined)[]): void { @@ -175,4 +176,8 @@ export class DesignLayoutConfig implements IDesignLayoutConfig { get supportExternalChatPanel(): boolean { return this.internalLayout.supportExternalChatPanel; } + + get panelLayout(): Required['panelLayout'] { + return this.internalLayout.panelLayout; + } } diff --git a/packages/core-browser/src/layout/layout-state.ts b/packages/core-browser/src/layout/layout-state.ts index c4558c4870..b741a05eb0 100644 --- a/packages/core-browser/src/layout/layout-state.ts +++ b/packages/core-browser/src/layout/layout-state.ts @@ -57,14 +57,20 @@ export class LayoutState { this.debounceSave(key, state); } + setStateSync(key: string, state: object) { + this.setStorageValue(key, state, this.shouldSaveWithWorkspace(key)); + } + private debounceSave = debounce((key, state) => { - this.setStorageValue( - key, - state, + this.setStorageValue(key, state, this.shouldSaveWithWorkspace(key)); + }, 200); + + private shouldSaveWithWorkspace(key: string) { + return ( LAYOUT_STATE.isScoped(key) || - (this.saveLayoutWithWorkspace && (LAYOUT_STATE.isLayout(key) || LAYOUT_STATE.isStatusBar(key))), + (this.saveLayoutWithWorkspace && (LAYOUT_STATE.isLayout(key) || LAYOUT_STATE.isStatusBar(key))) ); - }, 200); + } private setStorageValue(key: string, state: object, scope?: boolean) { if (scope) { 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..dc3f750f2b 100644 --- a/packages/core-common/src/settings/ai-native.ts +++ b/packages/core-common/src/settings/ai-native.ts @@ -8,6 +8,7 @@ export enum AINativeSettingSectionsId { InlineChatCodeActionEnabled = 'ai.native.inlineChat.codeAction.enabled', InterfaceQuickNavigationEnabled = 'ai.native.interface.quickNavigation.enabled', ChatVisibleType = 'ai.native.chat.visible.type', + PanelLayout = 'ai.native.panelLayout', /** * Whether to enable prompt engineering, some LLM models may not perform well on prompt engineering. @@ -47,6 +48,21 @@ 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', + + /** + * ACP: Maximum number of reusable agent threads. + */ + AcpThreadPoolSize = 'ai-native.acp.threadPoolSize', + /** * Default Agent Type */ @@ -54,6 +70,12 @@ export enum AINativeSettingSectionsId { TerminalAutoRun = 'ai.native.terminal.autorun', + /** + * WebMCP tool exposure profile for ACP agents. + */ + WebMcpEnabled = 'ai.native.webmcp.enabled', + WebMcpProfile = 'ai.native.webmcp.profile', + /** * Rules settings */ @@ -66,3 +88,4 @@ export enum AINativeSettingSectionsId { } export const AI_NATIVE_SETTING_GROUP_ID = 'AI-Native'; export const AI_NATIVE_SETTING_GROUP_TITLE = 'AI Native'; +export const DEFAULT_ACP_THREAD_POOL_SIZE = 10; 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..14bfa056a3 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,94 @@ 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; +export const AcpThreadStatusServicePath = 'AcpThreadStatusServicePath'; - /** - * Authenticate with the agent - */ - authenticate(params: AuthenticateRequest): Promise; - - /** - * Create a new session - */ - newSession(params: NewSessionRequest): Promise; - - /** - * Load an existing session - */ - loadSession(params: LoadSessionRequest): Promise; - - /** - * List all sessions - */ - listSessions(params?: ListSessionsRequest): Promise; - - /** - * Send a prompt to the session - */ - prompt(params: PromptRequest): Promise; +export interface IAcpThreadStatusService { + $onThreadStatusChange(sessionId: string, status: string): Promise; +} - /** - * Cancel an ongoing operation - */ - cancel(params: CancelNotification): Promise; +export type AcpDebugLogDirection = 'incoming' | 'outgoing' | 'stderr' | 'system'; + +export interface AcpDebugLogEntry { + id: number; + timestamp: number; + direction: AcpDebugLogDirection; + agentId: string; + threadId: string; + sessionId?: string; + raw: string; + payload?: unknown; +} - /** - * Change the session mode - */ - setSessionMode(params: SetSessionModeRequest): Promise; +// WebMCP Group types for OpenSumi IDE capability tools +export const AcpWebMcpBridgePath = 'AcpWebMcpBridgePath'; - /** - * Register a notification handler - * @returns Unsubscribe function - */ - onNotification(handler: (notification: SessionNotification) => void): () => void; +export type WebMcpToolRiskLevel = 'read' | 'write' | 'destructive' | 'shell' | 'ui'; +export type WebMcpProfile = 'minimal' | 'default' | 'interactive' | 'full'; +export interface WebMcpToolDef { + name: string; // "file_read" + description: string; + inputSchema: Record; /** - * Close the connection and cleanup resources + * Describes the tool's operational risk for catalog output, logging, and + * future policy evolution. It is not a complete authorization decision by + * itself; concrete tools still own their permission checks. */ - close(): Promise; - + riskLevel?: WebMcpToolRiskLevel; /** - * Check if currently connected + * Lightweight escape hatch for tools that should stay out of normal MCP + * exposure while the capability model is still being validated in practice. */ - isConnected(): boolean; - + exposedByDefault?: boolean; /** - * Handle unexpected disconnect + * Controls the default tool surface for each WebMCP profile. Session-level + * capability enablement may reveal additional tools, but execution-time + * safety must still live in the target tool. */ - handleDisconnect(): void; + profiles?: WebMcpProfile[]; +} - /** - * Register a disconnect handler, called when the connection is lost - * @returns Unsubscribe function - */ - onDisconnect(handler: () => void): () => void; +export interface WebMcpGroupDef { + name: string; + description: string; + defaultLoaded: boolean; + profile?: WebMcpProfile; + tools: WebMcpToolDef[]; +} - /** - * Get the negotiated protocol version - */ - getNegotiatedProtocolVersion(): number | null; +export interface WebMcpToolResult { + success: boolean; + result?: unknown; + error?: string; // machine-readable error code + details?: string; // human-readable error description +} - /** - * Get agent capabilities from initialize response - */ - getAgentCapabilities(): AgentCapabilities | null; +export interface OpenSumiMcpServerConnectionInfo { + name: string; + type: 'http'; + transport: 'streamable-http'; + url: string; + redactedUrl: string; + headers: Array<{ name: string; value: string }>; +} - /** - * Get agent info from initialize response - */ - getAgentInfo(): Implementation | null; +export interface WebMcpGroupInfo { + name: string; + description: string; + toolCount: number; + loaded: boolean; +} - /** - * Get available authentication methods - */ - getAuthMethods(): AuthMethod[]; +export interface WebMcpGroupDefinitionOptions { + includeAllTools?: boolean; +} - /** - * Get available session modes - */ - getSessionModes(): SessionModeState | null; +export interface IAcpWebMcpBridgeService { + $getGroupDefinitions(options?: WebMcpGroupDefinitionOptions): 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..d73be48c39 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,62 @@ 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[]; + /** + * OpenSumi built-in WebMCP exposure options for ACP sessions. + */ + webMcp?: { + enabled?: boolean; + }; + /** + * Maximum number of reusable ACP agent threads. + */ + threadPoolSize?: number; + /** + * Default ACP session model id to apply after session creation/loading. + */ + defaultModel?: string; + /** + * Default ACP session mode id to apply after session creation/loading. + */ + defaultMode?: string; + /** + * Default ACP session config option values keyed by config option id. + */ + defaultConfigOptions?: Record; } /** @@ -77,6 +122,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..39492a5737 100644 --- a/packages/core-common/src/types/ai-native/index.ts +++ b/packages/core-common/src/types/ai-native/index.ts @@ -5,7 +5,7 @@ import { SumiReadableStream } from '@opensumi/ide-utils/lib/stream'; import { FileType } from '../file'; import { IMarkdownString } from '../markdown'; -import { AvailableCommand, ListSessionsResponse } from './acp-types'; +import { AcpDebugLogEntry, AvailableCommand, ListSessionsResponse, OpenSumiMcpServerConnectionInfo } from './acp-types'; import { AgentProcessConfig } from './agent-types'; import { IAIReportCompletionOption } from './reporter'; @@ -13,6 +13,8 @@ import type { CoreMessage } from 'ai'; export * from './reporter'; export type { AvailableCommand }; +export type PanelLayoutMode = 'classic' | 'agentic'; + export interface IAINativeCapabilities { /** * Problem panel uses ai capabilities @@ -86,6 +88,10 @@ export interface IDesignLayoutConfig { * 是否支持插件注册 Chat 面板 */ supportExternalChatPanel?: boolean; + /** + * Panel layout mode for AI Native. + */ + panelLayout?: PanelLayoutMode; } export interface IAINativeInlineChatConfig { @@ -199,6 +205,40 @@ export interface IAIBackServiceOption { agentSessionConfig?: AgentProcessConfig; } +export interface AgentSessionModeOption { + id: string; + name: string; + description?: string; +} + +export interface AgentSessionModelOption { + modelId: string; + name: string; + description?: string | null; +} + +export interface AgentSessionStateResult { + modes?: AgentSessionModeOption[]; + currentModeId?: string; + models?: AgentSessionModelOption[]; + currentModelId?: string; + configOptions?: Record[]; +} + +export interface AgentSessionCreateResult extends AgentSessionStateResult { + sessionId: string; + availableCommands: AvailableCommand[]; +} + +export interface AgentSessionLoadResult extends AgentSessionStateResult { + sessionId: string; + messages: Array<{ + role: 'user' | 'assistant'; + content: string; + timestamp?: number; + }>; +} + /** * 补全请求对象 */ @@ -257,26 +297,18 @@ export interface IAIBackService< */ reportCompletion?(input: I): Promise; - loadAgentSession?( - config: AgentProcessConfig, - agentSessionId: string, - ): Promise<{ - sessionId: string; - messages: Array<{ - role: 'user' | 'assistant'; - content: string; - timestamp?: number; - }>; - }>; + loadAgentSession?(config: AgentProcessConfig, agentSessionId: string): Promise; listSessions?(config: AgentProcessConfig): Promise; - createSession?(config: AgentProcessConfig): Promise<{ - sessionId: string; - availableCommands: AvailableCommand[]; - }>; + createSession?(config: AgentProcessConfig): Promise; setSessionMode?(sessionId: string, modeId: string): Promise; + setSessionConfigOption?(sessionId: string, configId: string, value: boolean | string): Promise; + setSessionModel?(sessionId: string, model: string): Promise; + getAcpDebugLog?(): Promise; + clearAcpDebugLog?(): Promise; + getOpenSumiMcpServerConnection?(): Promise; ready?(): Promise; } @@ -466,6 +498,26 @@ 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 interface IChatSessionState { + kind: 'sessionState'; + sessionId: string; + currentModeId?: string; + currentModelId?: string; + configOptions?: Record[]; +} + export type IChatProgress = | IChatContent | IChatMarkdownContent @@ -473,7 +525,9 @@ export type IChatProgress = | IChatTreeData | IChatComponent | IChatToolContent - | IChatReasoning; + | IChatReasoning + | IChatThreadStatus + | IChatSessionState; export interface IChatMessage { role: ChatMessageRole; @@ -499,6 +553,7 @@ export interface IHistoryChatMessage extends IChatMessage { id: string; order: number; isSummarized?: boolean; // 添加这个属性,表示消息是否已被总结 + timestamp?: number; type?: 'string' | 'component'; images?: string[]; diff --git a/packages/file-service/__tests__/node/watcher-process-manager.test.ts b/packages/file-service/__tests__/node/watcher-process-manager.test.ts new file mode 100644 index 0000000000..1d63572f31 --- /dev/null +++ b/packages/file-service/__tests__/node/watcher-process-manager.test.ts @@ -0,0 +1,73 @@ +import path from 'path'; + +import { WatcherProcessManagerImpl } from '../../src/node/watcher-process-manager'; + +const createManager = (watcherHost?: string) => { + const manager = Object.create(WatcherProcessManagerImpl.prototype) as WatcherProcessManagerImpl; + Object.defineProperty(manager, 'appConfig', { + value: { watcherHost }, + }); + return manager; +}; + +describe('WatcherProcessManagerImpl', () => { + const originalExtMode = process.env.EXT_MODE; + const originalExecArgv = process.execArgv.slice(); + + afterEach(() => { + if (originalExtMode === undefined) { + delete process.env.EXT_MODE; + } else { + process.env.EXT_MODE = originalExtMode; + } + process.execArgv.splice(0, process.execArgv.length, ...originalExecArgv); + }); + + it('uses source watcher host in js mode when configured host is the default built host', () => { + process.env.EXT_MODE = 'js'; + const defaultBuiltWatcherHost = path.join(__dirname, '../../lib/node/hosted/watcher.process.js'); + const manager = createManager(defaultBuiltWatcherHost); + + expect(manager.watcherHost).toContain('packages/file-service/src/node/hosted/watcher.process.ts'); + }); + + it('keeps custom configured watcher host in js mode', () => { + process.env.EXT_MODE = 'js'; + const customWatcherHost = path.join(__dirname, 'custom-watcher.process.js'); + const manager = createManager(customWatcherHost); + + expect(manager.watcherHost).toBe(customWatcherHost); + }); + + it('keeps configured watcher host outside js mode', () => { + delete process.env.EXT_MODE; + const defaultBuiltWatcherHost = path.join(__dirname, '../../lib/node/hosted/watcher.process.js'); + const manager = createManager(defaultBuiltWatcherHost); + + expect(manager.watcherHost).toBe(defaultBuiltWatcherHost); + }); + + it('starts js-mode watcher process with clean transpile-only ts-node hooks', () => { + process.env.EXT_MODE = 'js'; + process.execArgv.splice( + 0, + process.execArgv.length, + '--require', + 'ts-node/register', + '--require', + 'source-map-support/register', + '--inspect=9999', + ); + + const execArgv = (createManager() as any).getWatcherProcessExecArgv(); + + expect(execArgv).toEqual([ + '--require', + 'ts-node/register/transpile-only', + '--require', + 'tsconfig-paths/register', + '--require', + 'source-map-support/register', + ]); + }); +}); diff --git a/packages/file-service/src/node/watcher-process-manager.ts b/packages/file-service/src/node/watcher-process-manager.ts index 6028d83361..2268d7deed 100644 --- a/packages/file-service/src/node/watcher-process-manager.ts +++ b/packages/file-service/src/node/watcher-process-manager.ts @@ -1,4 +1,5 @@ import { ChildProcess, fork } from 'child_process'; +import { existsSync } from 'fs'; import { Server, Socket, createServer } from 'net'; import path from 'path'; @@ -116,12 +117,98 @@ export class WatcherProcessManagerImpl implements IWatcherProcessManager { } get watcherHost() { - return ( - this.appConfig.watcherHost || - (process.env.EXT_MODE === 'js' - ? path.join(__dirname, '../../lib/node/hosted/watcher.process.js') - : path.join(__dirname, 'hosted', 'watcher.process.' + processUtil.extFileType)) - ); + if (process.env.EXT_MODE === 'js') { + if (!this.appConfig.watcherHost || this.isDefaultBuiltWatcherHost(this.appConfig.watcherHost)) { + return this.getSourceWatcherHost(); + } + } + + return this.appConfig.watcherHost || this.getBuiltWatcherHost(); + } + + private getBuiltWatcherHost() { + return path.join(__dirname, 'hosted', 'watcher.process.' + processUtil.extFileType); + } + + private getSourceWatcherHost() { + const sourceWatcherHost = path.join(__dirname, 'hosted', 'watcher.process.ts'); + if (existsSync(sourceWatcherHost)) { + return sourceWatcherHost; + } + return path.join(__dirname, '../../src/node/hosted/watcher.process.ts'); + } + + private isDefaultBuiltWatcherHost(watcherHost: string) { + const resolvedWatcherHost = path.resolve(watcherHost); + const hostNames = Array.from(new Set(['watcher.process.js', 'watcher.process.' + processUtil.extFileType])); + + return hostNames + .flatMap((hostName) => [ + path.join(__dirname, 'hosted', hostName), + path.join(__dirname, '../../lib/node/hosted', hostName), + ]) + .map((candidate) => path.resolve(candidate)) + .includes(resolvedWatcherHost); + } + + private getWatcherProcessExecArgv() { + if (process.env.EXT_MODE !== 'js') { + return process.execArgv; + } + + const execArgv: string[] = []; + for (let index = 0; index < process.execArgv.length; index++) { + const arg = process.execArgv[index]; + if (arg.startsWith('--inspect')) { + continue; + } + if (arg === '--require' || arg === '-r') { + const moduleName = process.execArgv[index + 1]; + if (moduleName?.startsWith('ts-node/register') || moduleName === 'tsconfig-paths/register') { + index++; + continue; + } + if (moduleName) { + execArgv.push(arg, moduleName); + } else { + execArgv.push(arg); + } + index++; + continue; + } + if (arg.startsWith('--require=')) { + const moduleName = arg.slice('--require='.length); + if (moduleName.startsWith('ts-node/register') || moduleName === 'tsconfig-paths/register') { + continue; + } + } + execArgv.push(arg); + } + const ensureRequire = (moduleName: string) => { + if ( + execArgv.includes(moduleName) || + execArgv.includes(`--require=${moduleName}`) || + execArgv.some((arg, index) => arg === '--require' && execArgv[index + 1] === moduleName) || + execArgv.some((arg, index) => arg === '-r' && execArgv[index + 1] === moduleName) + ) { + return; + } + execArgv.unshift(moduleName); + execArgv.unshift('--require'); + }; + + ensureRequire('tsconfig-paths/register'); + ensureRequire('ts-node/register/transpile-only'); + + return execArgv; + } + + private getWatcherProcessCwd() { + if (process.env.EXT_MODE !== 'js') { + return process.cwd(); + } + + return path.join(__dirname, '../../../..'); } private async createWatcherProcess(clientId: string, ipcHandlerPath: string, backend?: RecursiveWatcherBackend) { @@ -140,6 +227,8 @@ export class WatcherProcessManagerImpl implements IWatcherProcessManager { this.logger.log('Watcher process path: ', this.watcherHost); this.watcherProcess = fork(this.watcherHost, forkArgs, { silent: true, + execArgv: this.getWatcherProcessExecArgv(), + cwd: this.getWatcherProcessCwd(), }); this.logger.log('Watcher process fork success, pid: ', this.watcherProcess.pid); diff --git a/packages/i18n/src/common/en-US.lang.ts b/packages/i18n/src/common/en-US.lang.ts index 332eda35a6..09641e098c 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', @@ -1650,6 +1651,8 @@ export const localizationBundle = { 'ai.native.terminal.autorun': 'Terminal auto execution policy', 'ai.native.terminal.autorun.description': 'The auto-execution policy for Agent terminal commands. off means never auto-execute, auto means the model will decide whether to auto-execute based on the command (only available on premium models), Always means always auto-execute.', + 'preference.ai.native.webmcp.enabled': 'Expose OpenSumi IDE capabilities to agents', + 'preference.ai.native.webmcp.profile': 'OpenSumi IDE capability profile', 'ai.native.terminal.autorun.denied': 'Auto-run denied by default', 'ai.native.terminal.autorun.question': 'Want to run this automatically in the future?', @@ -1668,6 +1671,8 @@ export const localizationBundle = { 'ai.native.mcp.type': 'Type:', 'ai.native.mcp.stdio': 'Command', 'ai.native.mcp.sse': 'SSE', + 'ai.native.layout.agentic': 'Agent', + 'ai.native.layout.classic': 'Classic', 'ai.native.mcp.buttonSave': 'Add', 'ai.native.mcp.buttonUpdate': 'Update', 'ai.native.mcp.buttonCancel': 'Cancel', diff --git a/packages/i18n/src/common/zh-CN.lang.ts b/packages/i18n/src/common/zh-CN.lang.ts index 914f03c115..a742412c52 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': '拒绝全部', @@ -1406,6 +1407,8 @@ export const localizationBundle = { 'ai.native.terminal.autorun': '终端命令自动执行策略', 'ai.native.terminal.autorun.description': 'Agent终端命令的自动执行策略。`off` 表示永远不自动执行,`auto` 表示模型将根据命令决定是否自动执行(只适用于高级模型),`always` 表示永远自动执行。', + 'preference.ai.native.webmcp.enabled': '向 Agent 暴露 OpenSumi IDE 能力', + 'preference.ai.native.webmcp.profile': 'OpenSumi IDE 能力范围', 'ai.native.terminal.autorun.denied': '默认情况下拒绝自动运行', 'ai.native.terminal.autorun.question': '希望自动运行?', @@ -1423,6 +1426,8 @@ export const localizationBundle = { 'ai.native.mcp.type': '类型:', 'ai.native.mcp.stdio': 'Command', 'ai.native.mcp.sse': 'SSE', + 'ai.native.layout.agentic': 'Agent', + 'ai.native.layout.classic': 'Classic', 'ai.native.mcp.buttonSave': '添加', 'ai.native.mcp.buttonUpdate': '更新', 'ai.native.mcp.buttonCancel': '取消', diff --git a/packages/main-layout/__tests__/browser/layout.service.test.tsx b/packages/main-layout/__tests__/browser/layout.service.test.tsx index 5901f9a796..5253f8ed0a 100644 --- a/packages/main-layout/__tests__/browser/layout.service.test.tsx +++ b/packages/main-layout/__tests__/browser/layout.service.test.tsx @@ -17,16 +17,15 @@ import { useMockStorage } from '@opensumi/ide-core-browser/__mocks__/storage'; import { ClientApp } from '@opensumi/ide-core-browser/lib/bootstrap/app'; import { LayoutState } from '@opensumi/ide-core-browser/lib/layout/layout-state'; import { CommonServerPath, Deferred, ILoggerManagerClient, OS } from '@opensumi/ide-core-common'; +import { MockInjector } from '@opensumi/ide-dev-tool/src/mock-injector'; import { IMainLayoutService } from '@opensumi/ide-main-layout'; import { MainLayoutModule } from '@opensumi/ide-main-layout/lib/browser'; import { LayoutService } from '@opensumi/ide-main-layout/lib/browser/layout.service'; import { MainLayoutModuleContribution } from '@opensumi/ide-main-layout/lib/browser/main-layout.contribution'; +import { MockContextKeyService } from '@opensumi/ide-monaco/__mocks__/monaco.context-key.service'; import { IconService } from '@opensumi/ide-theme/lib/browser/icon.service'; import { IIconService } from '@opensumi/ide-theme/lib/common/theme.service'; -import { MockInjector } from '../../../../tools/dev-tool/src/mock-injector'; -import { MockContextKeyService } from '../../../monaco/__mocks__/monaco.context-key.service'; - const MockView = (props) =>
Test view{props.message &&

has prop.message

}
; jest.useFakeTimers(); @@ -119,7 +118,7 @@ describe('main layout test', () => { ready: Promise.resolve(), get: () => undefined, onPreferenceChanged: () => Disposable.create(() => {}), - onSpecificPreferenceChange: (func: any) => Disposable.create(() => {}), + onSpecificPreferenceChange: () => Disposable.create(() => {}), }, }, { @@ -444,4 +443,94 @@ describe('main layout test', () => { }); expect(service.isVisible(SlotLocation.extendView)).toBeFalsy(); }); + + it('should store tabbar state into the active layout state key', () => { + const layoutStorageKey = 'layout.ai.agentic'; + const layoutState = injector.get(LayoutState); + const setStateSpy = jest.spyOn(layoutState, 'setState'); + + act(() => { + service.setLayoutStateKey(layoutStorageKey); + service.storeState( + { + location: 'AI-Chat', + prevSize: 1080, + } as any, + 'AI-Chat-Container', + ); + }); + + expect(setStateSpy).toHaveBeenCalledWith( + layoutStorageKey, + expect.objectContaining({ + 'AI-Chat': { + currentId: 'AI-Chat-Container', + size: 1080, + }, + }), + ); + act(() => { + service.setLayoutStateKey('layout'); + }); + setStateSpy.mockRestore(); + }); + + it('should store delayed tabbar state into the captured layout state key', () => { + const layoutStorageKey = 'layout.ai.agentic'; + const layoutState = injector.get(LayoutState); + const setStateSpy = jest.spyOn(layoutState, 'setState'); + + act(() => { + service.setLayoutStateKey('layout'); + service.storeState( + { + location: 'AI-Chat', + prevSize: 1080, + } as any, + 'AI-Chat-Container', + layoutStorageKey, + ); + }); + + expect(setStateSpy).toHaveBeenCalledWith( + layoutStorageKey, + expect.objectContaining({ + 'AI-Chat': { + currentId: 'AI-Chat-Container', + size: 1080, + }, + }), + ); + setStateSpy.mockRestore(); + }); + + it('should force restore tabbar services when setting the active layout state key again', () => { + const layoutStorageKey = 'layout.ai.agentic'; + const layoutState = injector.get(LayoutState); + const rightTabbarService = service.getTabbarService(SlotLocation.extendView); + const setSizeSpy = jest.spyOn(rightTabbarService.resizeHandle!, 'setSize').mockImplementation(() => {}); + + layoutState.setStateSync(layoutStorageKey, { + [SlotLocation.extendView]: { + currentId: testContainerId, + size: 321, + }, + }); + + act(() => { + service.setLayoutStateKey(layoutStorageKey, { saveCurrent: false }); + }); + setSizeSpy.mockClear(); + + act(() => { + service.setLayoutStateKey(layoutStorageKey, { saveCurrent: false, forceRestore: true }); + }); + + expect(setSizeSpy).toHaveBeenCalledWith(321); + + act(() => { + service.setLayoutStateKey('layout'); + }); + setSizeSpy.mockRestore(); + }); }); diff --git a/packages/main-layout/__tests__/browser/tabbar.view.test.tsx b/packages/main-layout/__tests__/browser/tabbar.view.test.tsx new file mode 100644 index 0000000000..8ae68355ad --- /dev/null +++ b/packages/main-layout/__tests__/browser/tabbar.view.test.tsx @@ -0,0 +1,170 @@ +import React from 'react'; +import { Root, createRoot } from 'react-dom/client'; +import { act } from 'react-dom/test-utils'; + +const mockTabbarServiceFactoryToken = Symbol('TabbarServiceFactory'); +const mockProgressServiceToken = Symbol('IProgressService'); +const mockKeybindingRegistryToken = Symbol('KeybindingRegistry'); +let mockVisibleContainers: any[] = []; +let mockCurrentContainerId = 'explorer'; + +const mockTabbarService = { + currentContainerId: { + get: jest.fn(() => mockCurrentContainerId), + }, + visibleContainers: mockVisibleContainers, + updateBarSize: jest.fn(), + updateTabInMoreKey: jest.fn(), + handleDragStart: jest.fn(), + handleDragEnd: jest.fn(), + handleDrop: jest.fn(), + handleContextMenu: jest.fn(), + handleTabClick: jest.fn(), + showMoreMenu: jest.fn(), + onDidRegisterContainer: jest.fn(), + onStateChange: jest.fn(), +}; + +const mockProgressService = { + getIndicator: jest.fn(() => undefined), +}; + +jest.mock('@opensumi/ide-components', () => ({ + Badge: ({ children }: React.PropsWithChildren) => {children}, + Icon: () => , +})); + +jest.mock('@opensumi/ide-core-browser', () => ({ + BasicEvent: class BasicEvent { + constructor(public payload: T) {} + }, + Event: { + any: () => () => ({ dispose: jest.fn() }), + }, + KeybindingRegistry: mockKeybindingRegistryToken, + SlotLocation: { + extendView: 'extendView', + panel: 'panel', + view: 'view', + }, + addClassName: jest.fn(), + getIcon: (icon: string) => `icon-${icon}`, + useAutorun: (value: any) => (typeof value?.get === 'function' ? value.get() : value), + useDesignStyles: (className: string) => className, + useInjectable: (token: any) => { + if (token === mockTabbarServiceFactoryToken) { + return () => mockTabbarService; + } + if (token === mockProgressServiceToken) { + return mockProgressService; + } + if (token === mockKeybindingRegistryToken) { + return { + acceleratorForKeyString: (key: string) => key, + }; + } + return {}; + }, + usePreference: (_key: string, defaultValue: boolean) => defaultValue, +})); + +jest.mock('@opensumi/ide-core-browser/lib/components/actions', () => ({ + InlineMenuBar: () => , +})); + +jest.mock('@opensumi/ide-core-browser/lib/components/layout/layout', () => ({ + Layout: { + getFlexDirection: () => 'row', + getTabbarDirection: () => 'column', + }, +})); + +jest.mock('@opensumi/ide-core-browser/lib/layout/view-id', () => ({ + VIEW_CONTAINERS: { + LEFT_TABBAR: 'left-tabbar', + }, +})); + +jest.mock('@opensumi/ide-core-browser/lib/progress', () => ({ + IProgressService: mockProgressServiceToken, +})); + +jest.mock('@opensumi/ide-monaco/lib/common/observable', () => ({ + observableValue: () => ({ + get: () => false, + }), +})); + +jest.mock('../../src/browser/tabbar/tabbar.service', () => ({ + TabbarServiceFactory: mockTabbarServiceFactoryToken, +})); + +jest.mock('../../src/browser/tabbar/renderer.view', () => { + const React = require('react'); + return { + TabbarConfig: React.createContext({ side: 'view', direction: 'left-to-right', fullSize: 480 }), + }; +}); + +describe('TabbarViewBase', () => { + let container: HTMLDivElement; + let root: Root; + + const renderTabbar = (containerFilter?: (component: any) => boolean) => { + const { TabbarViewBase } = require('../../src/browser/tabbar/bar.view'); + const { TabbarConfig } = require('../../src/browser/tabbar/renderer.view'); + const TabView = ({ component }: { component: any }) => ( + {component.options.containerId} + ); + const MoreTabView = () => more; + + act(() => { + root.render( + + + , + ); + }); + }; + + beforeEach(() => { + mockCurrentContainerId = 'explorer'; + mockVisibleContainers = [ + { options: { containerId: 'explorer' } }, + { options: { containerId: 'search' } }, + { options: { containerId: 'scm' } }, + { options: { containerId: 'debug' } }, + { options: { containerId: 'extension' } }, + { options: { containerId: 'hidden', hideTab: true } }, + ]; + mockTabbarService.visibleContainers = mockVisibleContainers; + mockTabbarService.updateBarSize.mockClear(); + mockTabbarService.updateTabInMoreKey.mockClear(); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + }); + + it('renders all non-hidden containers when no filter is provided', () => { + renderTabbar(); + + expect( + Array.from(container.querySelectorAll('[data-testid="tabbar-entry"]')).map((node) => node.textContent), + ).toEqual(['explorer', 'search', 'scm', 'debug', 'extension']); + }); + + it('applies the optional container filter to visible entries', () => { + renderTabbar((component) => ['explorer', 'scm'].includes(component.options?.containerId)); + + expect( + Array.from(container.querySelectorAll('[data-testid="tabbar-entry"]')).map((node) => node.textContent), + ).toEqual(['explorer', 'scm']); + }); +}); diff --git a/packages/main-layout/src/browser/layout.service.ts b/packages/main-layout/src/browser/layout.service.ts index be76753476..2f813906ad 100644 --- a/packages/main-layout/src/browser/layout.service.ts +++ b/packages/main-layout/src/browser/layout.service.ts @@ -18,7 +18,7 @@ import { WithEventBus, slotRendererRegistry, } from '@opensumi/ide-core-browser'; -import { Layout, fixLayout } from '@opensumi/ide-core-browser/lib/components'; +import { fixLayout } from '@opensumi/ide-core-browser/lib/components'; import { LAYOUT_STATE, LayoutState } from '@opensumi/ide-core-browser/lib/layout/layout-state'; import { ComponentRegistryInfo } from '@opensumi/ide-core-browser/lib/layout/layout.interface'; import { @@ -37,6 +37,7 @@ import { DROP_PANEL_CONTAINER, DROP_VIEW_CONTAINER, IMainLayoutService, + LayoutStateKeyOptions, MainLayoutContribution, SUPPORT_ACCORDION_LOCATION, ViewComponentOptions, @@ -115,6 +116,8 @@ export class LayoutService extends WithEventBus implements IMainLayoutService { }; } = {}; + private layoutStateKey = LAYOUT_STATE.MAIN; + // 记录正在恢复状态的 location,防止恢复过程中存储中间状态 private isRestoring = new Set(); @@ -130,6 +133,31 @@ export class LayoutService extends WithEventBus implements IMainLayoutService { public viewReady: Deferred = new Deferred(); + setLayoutStateKey(key: string, options: LayoutStateKeyOptions = {}): void { + const nextLayoutStateKey = key || LAYOUT_STATE.MAIN; + if (this.layoutStateKey === nextLayoutStateKey) { + if (options.forceRestore && this.tabbarServices.size) { + this.restoreAllTabbarServices(); + } + return; + } + + if (!this.tabbarServices.size) { + this.layoutStateKey = nextLayoutStateKey; + return; + } + + if (options.saveCurrent !== false) { + this.layoutState.setStateSync(this.layoutStateKey, this.getCurrentLayoutStateSnapshot()); + } + this.layoutStateKey = nextLayoutStateKey; + this.restoreAllTabbarServices(); + } + + getLayoutStateKey(): string { + return this.layoutStateKey; + } + didMount() { for (const [containerId, views] of this.pendingViewsMap.entries()) { views.forEach(({ view, props }) => { @@ -156,18 +184,29 @@ export class LayoutService extends WithEventBus implements IMainLayoutService { }); } - storeState(service: TabbarService, currentId?: string) { + storeState(service: TabbarService, currentId?: string, layoutStateKey = this.layoutStateKey) { + if (!service.location) { + return; + } + // 如果正在恢复中,跳过存储,避免存储中间状态 if (this.isRestoring.has(service.location)) { return; } - this.state[service.location] = { + const state = + layoutStateKey === this.layoutStateKey + ? this.state + : fixLayout(this.layoutState.getState(layoutStateKey, defaultLayoutState)); + state[service.location] = { currentId, size: service.prevSize, }; + if (layoutStateKey === this.layoutStateKey) { + this.state = state; + } - this.layoutState.setState(LAYOUT_STATE.MAIN, this.state); + this.layoutState.setState(layoutStateKey, state); } @OnEvent(ThemeChangedEvent) @@ -186,7 +225,37 @@ export class LayoutService extends WithEventBus implements IMainLayoutService { } restoreTabbarService = async (service: TabbarService) => { - this.state = fixLayout(this.layoutState.getState(LAYOUT_STATE.MAIN, defaultLayoutState)); + this.state = this.getStoredLayoutState(); + this.applyLayoutStateToTabbarService(service); + }; + + private getStoredLayoutState() { + return fixLayout(this.layoutState.getState(this.layoutStateKey, defaultLayoutState)); + } + + private getCurrentLayoutStateSnapshot() { + const nextState = { ...this.state }; + for (const service of this.tabbarServices.values()) { + if (!service.location) { + continue; + } + + nextState[service.location] = { + currentId: service.currentContainerId.get(), + size: service.prevSize, + }; + } + return nextState; + } + + private restoreAllTabbarServices() { + this.state = this.getStoredLayoutState(); + for (const service of this.tabbarServices.values()) { + this.applyLayoutStateToTabbarService(service); + } + } + + private applyLayoutStateToTabbarService(service: TabbarService) { const { currentId, size } = this.state[service.location] || {}; service.prevSize = size; @@ -208,8 +277,16 @@ export class LayoutService extends WithEventBus implements IMainLayoutService { } const defaultContainer = this.getDefaultContainer(service); - this.restoreContainerId(service, currentId, defaultContainer); - }; + this.isRestoring.add(service.location); + try { + this.restoreContainerId(service, currentId, defaultContainer); + if (service.currentContainerId.get() && size) { + service.resizeHandle?.setSize(size); + } + } finally { + this.isRestoring.delete(service.location); + } + } private getDefaultContainer(service: TabbarService): string | undefined { const defaultPanels = this.appConfig.defaultPanels; @@ -455,8 +532,10 @@ export class LayoutService extends WithEventBus implements IMainLayoutService { this.isRestoring.delete(service.location); this.logger.error(`[TabbarService:${location}] restore state error`, err); }); - const debouncedStoreState = debounce(() => this.storeState(service, service.currentContainerId.get()), 100); - service.addDispose(service.onSizeChange(debouncedStoreState)); + const debouncedStoreState = debounce((layoutStateKey: string) => { + this.storeState(service, service.currentContainerId.get(), layoutStateKey); + }, 100); + service.addDispose(service.onSizeChange(() => debouncedStoreState(this.layoutStateKey))); if (location === SlotLocation.panel) { // use this getter's side effect to set bottomExpanded contextKey const debouncedUpdate = debounce(() => void this.bottomExpanded, 100); diff --git a/packages/main-layout/src/browser/tabbar/bar.view.tsx b/packages/main-layout/src/browser/tabbar/bar.view.tsx index 056bb06a27..4ee2d296a3 100644 --- a/packages/main-layout/src/browser/tabbar/bar.view.tsx +++ b/packages/main-layout/src/browser/tabbar/bar.view.tsx @@ -65,6 +65,7 @@ export interface ITabbarViewProps { // tab上预留的位置,用来控制tab过多的显示效果 margin?: number; canHideTabbar?: boolean; + containerFilter?: (component: ComponentRegistryInfo) => boolean; renderOtherVisibleContainers?: FC<{ props: ITabbarViewProps; renderContainers: ( @@ -87,6 +88,7 @@ export const TabbarViewBase: FC = (props) => { margin, tabSize, canHideTabbar, + containerFilter, renderOtherVisibleContainers = () => null, disableAutoAdjust, } = props; @@ -98,11 +100,15 @@ export const TabbarViewBase: FC = (props) => { () => (disableAutoAdjust ? Number.MAX_SAFE_INTEGER : Math.floor(fullSize - (margin || 0) / tabSize)), [disableAutoAdjust, fullSize, margin, tabSize], ); + const getVisibleContainers = useCallback( + () => + tabbarService.visibleContainers.filter( + (container) => !container.options?.hideTab && (!containerFilter || containerFilter(container)), + ), + [containerFilter, tabbarService], + ); const [containers, setContainers] = useState( - splitVisibleTabs( - tabbarService.visibleContainers.filter((container) => !container.options?.hideTab), - visibleCount, - ), + splitVisibleTabs(getVisibleContainers(), visibleCount), ); useEffect(() => { @@ -112,12 +118,7 @@ export const TabbarViewBase: FC = (props) => { useEffect(() => { const updateContainers = () => { - setContainers( - splitVisibleTabs( - tabbarService.visibleContainers.filter((container) => !container.options?.hideTab), - visibleCount, - ), - ); + setContainers(splitVisibleTabs(getVisibleContainers(), visibleCount)); }; updateContainers(); @@ -128,7 +129,7 @@ export const TabbarViewBase: FC = (props) => { return () => { disposable.dispose(); }; - }, [visibleCount]); + }, [getVisibleContainers, visibleCount]); const currentContainerId = useAutorun(tabbarService.currentContainerId); const hideTabBarWhenHidePanel = usePreference('workbench.hideSlotTabBarWhenHidePanel', false); diff --git a/packages/main-layout/src/common/main-layout.definition.ts b/packages/main-layout/src/common/main-layout.definition.ts index 55be5b9898..356b359743 100644 --- a/packages/main-layout/src/common/main-layout.definition.ts +++ b/packages/main-layout/src/common/main-layout.definition.ts @@ -1,5 +1,4 @@ import { BasicEvent, IDisposable, SlotLocation } from '@opensumi/ide-core-browser'; -import { Layout } from '@opensumi/ide-core-browser/lib/components'; import { SideStateManager, View, ViewContainerOptions } from '@opensumi/ide-core-browser/lib/layout'; import { ComponentRegistryInfo } from '@opensumi/ide-core-browser/lib/layout/layout.interface'; import { IContextMenu } from '@opensumi/ide-core-browser/lib/menu/next'; @@ -23,11 +22,22 @@ export interface ViewComponentOptions { fromExtension?: boolean; } +export interface LayoutStateKeyOptions { + saveCurrent?: boolean; + forceRestore?: boolean; +} + export const IMainLayoutService = Symbol('IMainLayoutService'); export interface IMainLayoutService { viewReady: Deferred; didMount(): void; + /** + * Set the active layout state profile key. + * Defaults to `layout`; custom layouts can opt into their own layout profile. + */ + setLayoutStateKey(key: string, options?: LayoutStateKeyOptions): void; + getLayoutStateKey(): string; /** * 切换tabbar位置的slot,传 slot id */ 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/startup/entry/web/e2e/app.tsx b/packages/startup/entry/web/e2e/app.tsx index 831091271d..712fcc3a5f 100644 --- a/packages/startup/entry/web/e2e/app.tsx +++ b/packages/startup/entry/web/e2e/app.tsx @@ -1,15 +1,38 @@ +import { AILayout } from '@opensumi/ide-ai-native/lib/browser/layout/ai-layout'; +import { AIModules } from '@opensumi/ide-startup/lib/browser/common-modules'; + import { DefaultLayout } from '../layout'; import { getDefaultClientAppOpts, renderApp } from '../render-app'; +const queries = new URLSearchParams(window.location.search); +const enableAINativeE2E = queries.get('aiNative') === 'true' || queries.has('webMcpProfile'); +const panelLayout = queries.get('aiPanelLayout') === 'classic' ? 'classic' : 'agentic'; + renderApp( getDefaultClientAppOpts({ + modules: enableAINativeE2E ? AIModules : [], opts: { - // do not use design and ai layout for e2e testing - designLayout: { - useMenubarView: false, - useMergeRightWithLeftPanel: false, - }, - layoutComponent: DefaultLayout, + ...(enableAINativeE2E + ? { + AINativeConfig: { + layout: { + panelLayout, + }, + capabilities: { + supportsMCP: true, + supportsCustomLLMSettings: true, + }, + }, + layoutComponent: AILayout, + } + : { + // do not use design and ai layout for general e2e testing + designLayout: { + useMenubarView: false, + useMergeRightWithLeftPanel: false, + }, + layoutComponent: DefaultLayout, + }), }, }), ); 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/.gitignore b/test/bdd/.gitignore new file mode 100644 index 0000000000..e0dadae3fa --- /dev/null +++ b/test/bdd/.gitignore @@ -0,0 +1,3 @@ +# BDD execution evidence — generated by /bdd-run, not committed +evidence/ +.last-failure.md diff --git a/test/bdd/PRODUCT_MANUAL.md b/test/bdd/PRODUCT_MANUAL.md new file mode 100644 index 0000000000..71560c6e3f --- /dev/null +++ b/test/bdd/PRODUCT_MANUAL.md @@ -0,0 +1,406 @@ +# AI Native IDE Product Manual From ACP / WebMCP BDD + +本文档把 `test/bdd/` 目录中的 BDD 验收场景整理成一份产品手册,用于判断 OpenSumi AI Native IDE 的 ACP Chat、Agentic 布局、WebMCP 能力和 IDE 工具调用是否达到了可发布、可信、可集成的产品状态。 + +它不替代具体 BDD 用例。某个行为是否必须满足,仍以对应 `.scenario.md` 的 `Given / When / Then / Pass / Fail Judgment` 为准。本文档负责回答产品问题:这些用例共同证明了什么用户价值,哪些能力还不能发布,新增用例应该放在哪一层。 + +## 产品定位 + +AI Native IDE 不是在 IDE 旁边放一个聊天窗口,而是让 Agent 成为 IDE 工作流的一部分。用户可以把有限范围内的任务交给 Agent,同时保留上下文选择权、权限决策权、过程可见性和失败恢复能力。 + +核心承诺: + +- 开发者可以在 IDE 内发起任务、附加上下文、观察 Agent 的计划和工具执行,并随时停止或恢复。 +- Agent 可以通过稳定的 WebMCP / MCP 能力发现和调用 IDE 功能,但不能越过当前 Profile、工作区边界和权限门禁。 +- IDE 始终保持可用。Chat、Explorer、Editor、Terminal、状态栏和布局切换不能互相破坏。 +- 敏感内容默认不外泄。状态、历史、权限、调试、relay 和工具目录都必须有明确的内容边界。 + +## 读者速览 + +| 读者 | 该关心什么 | 推荐章节 | +| ------------------ | ------------------------------------------------ | --------------------------------------- | +| 产品经理 | AI Native IDE 是否形成可信闭环,哪些能力可发布。 | 产品定位、核心体验原则、发布准入 | +| 测试/质量 | BDD 场景是否覆盖用户路径、权限边界和失败恢复。 | BDD 覆盖矩阵、BDD 评审规则 | +| 研发 | ACP、WebMCP、Node 合约和 UI 行为应如何落地。 | 能力模型、Profile 模型、Node 运行时契约 | +| Agent / MCP 集成方 | 如何发现工具、连接 MCP、理解权限和错误。 | WebMCP 与 MCP 集成 | +| 安全/平台 | 哪些信息可以暴露,哪些操作必须受控。 | 安全与信任模型、发布准入 | + +## 核心体验原则 + +| 原则 | 产品含义 | 不可接受情况 | +| --- | --- | --- | +| 上下文可控 | 用户能看到自己附加了哪些文件、片段、规则或编辑器上下文,也能在发送前删除。 | 删除的 chip 仍被发送,或 UI 暴露隐藏 payload wrapper。 | +| 行动可见 | Agent 的 streaming、计划、推理、工具调用和失败状态都要能被用户理解。 | 长时间只有 spinner,工具卡片重复,失败后输入框不可用。 | +| 权限可审计 | 写入、relay、终端、文件、调试等高风险动作必须受 Profile 和可见 UI 权限控制。 | 通过 MCP 自动 approve/reject,或权限状态泄露请求正文。 | +| 会话可恢复 | 历史、新建会话、切换、刷新、取消和失败重试都不能破坏会话归属。 | A 会话的流式更新污染 B,会话刷新后重复消息或丢工具结果。 | +| IDE 不退化 | Agentic 布局不能让 Explorer、Editor、Terminal 和状态栏失效。 | 切换布局刷新页面、Workbench 过窄、Explorer 无法打开文件。 | +| 集成稳定 | 外部能力使用 lower-snake canonical 名称,并由 Profile 控制可见范围。 | `_opensumi/...`、camelCase 或旧 ACP direct tools 作为正向路径出现。 | +| 默认最小暴露 | default Profile 只提供安全状态和打开 Chat 能力。 | 默认 Profile 可发送消息、读历史正文、修改文件或运行终端命令。 | + +## 用户与关键任务 + +| 用户 | 关键任务 | 产品验收信号 | +| --- | --- | --- | +| 普通开发者 | 打开 IDE,向 Agent 提问,附加代码上下文,观察回答,必要时停止。 | Chat 可聚焦、发送不重复、Stop 后可继续、上下文 chip 可删除。 | +| 高阶开发者 | 在多个任务之间切换,保留历史,恢复复杂工具结果。 | 多会话隔离,刷新后历史一致,复杂响应不丢不重。 | +| Agent 使用者 | 让 Agent 调用文件、搜索、诊断、编辑器和终端能力。 | 工具可发现,结果有界,写操作仅在 full Profile 且可清理。 | +| MCP 集成方 | 通过 loopback MCP 连接 IDE,调用 canonical tools。 | bridge token 不泄露,`tools/list` 与 browser surface 对齐。 | +| 企业平台/安全 | 控制不同环境下的能力暴露和敏感信息输出。 | Profile 边界不可绕过,state/list/permission/debug/relay 不泄露内容。 | +| 研发/测试 | 用 BDD 判断实现是否满足产品契约。 | 用例声明 Layer/Profile/Fixtures/Mutation/Automation status,失败原因可定位。 | + +## 核心用户旅程 + +### 1. 打开 AI Native IDE + +用户启动 OpenSumi IDE,进入带工作区的 URL。页面完成加载后,Agentic Chat 应在左侧主要列可见,Workbench、Explorer、Editor 和状态栏仍可用。 + +验收重点: + +- Agentic Chat 默认可打开,输入框可聚焦。 +- Agentic 下 Chat 宽度在 `640px` 到 `1440px` 之间,Workbench 宽度至少 `480px`。 +- Classic 和 Agentic 可双向切换,切换不刷新页面、不离开当前 workspace URL。 +- 主题、布局偏好和尺寸刷新后恢复到可用状态。 + +### 2. 发送第一条消息 + +用户聚焦输入框,输入内容,选择 slash command 或 mention 上下文,点击发送。第一条有效消息创建或激活 ACP Session。 + +验收重点: + +- 空白输入不会创建消息或会话。 +- 用户消息只出现一次,并位于助手响应之前。 +- 助手响应从 streaming 收敛为稳定消息,不重复行。 +- 发送失败后输入框恢复可编辑,用户可以重试。 +- `acp_chat_get_session_state` 只返回 metadata,不返回 prompt、assistant 正文、工具结果或权限正文。 + +### 3. 让 Agent 解释过程 + +用户发送确定性任务后,界面逐步展示工作状态、推理内容、计划内容、助手正文和工具调用卡片。 + +验收重点: + +- 推理和计划归属于同一条助手响应。 +- 同一个 tool call id 的更新会更新现有卡片,不新增重复卡片。 +- 工具卡片可展开查看工具名、参数区和结果区。 +- BDD 只能验证 UI 转换和 fixture 输出,不断言真实 LLM 文本。 + +### 4. 控制长任务 + +用户在长流式响应期间点击 Stop/Cancel,然后继续在同一会话发送后续消息。 + +验收重点: + +- Stop/Cancel 只在 active request 期间可用。 +- 取消后用户消息保留,助手行不再卡在纯 spinner 状态。 +- 输入框恢复可编辑,同一 session 可继续发送。 +- 旧 ACP direct tools 不能作为取消路径出现。 + +### 5. 管理历史和多会话 + +用户点击 New Chat 进入草稿态,发送后历史出现稳定标题。用户可以在多个会话之间切换,并在流式响应期间切到另一个会话。 + +验收重点: + +- 未发送的空草稿不能落成 `(untitled)` 或 `New Session` 垃圾历史。 +- 历史按 ACP 期望顺序展示,通常为 newest first。 +- 当前选中项和 `acp_chat_get_session_state` 一致。 +- 非当前 session 的流式更新不能污染当前聊天窗口。 +- 历史/list 工具不能返回消息正文或工具结果。 + +### 6. 恢复复杂会话 + +用户打开包含正文、推理、计划和工具结果的复杂会话,切换到其他会话,再切回或刷新页面。 + +验收重点: + +- 切换/刷新不能产生重复消息、重复工具卡片或空会话。 +- 复杂响应结构仍与同一条助手响应关联。 +- 工具卡片展开状态可以重置,但底层工具卡片和结果必须可恢复。 + +### 7. 处理权限 + +full Profile 下,用户触发需要权限的动作。IDE 弹出可见权限弹窗,用户通过 UI reject 或关闭。 + +验收重点: + +- 权限决策必须通过可见浏览器 UI 完成。 +- ACP/WebMCP 不暴露自动 approve/reject 工具。 +- Permission state 只能暴露 `activeDialogCount`、`activeSessionId`、`pendingCountExcludingActive` 等计数/作用域。 +- 弹窗没有稳定 Reject/close selector 时,场景应为 `BLOCKED`,不是假装通过。 + +### 8. 通过 MCP 调用 IDE 能力 + +Agent 或外部 MCP 客户端通过 browser WebMCP surface 获取 loopback MCP bridge URL,再连接 IDE 内置 `opensumi-ide` server。 + +验收重点: + +- bridge 只监听 `127.0.0.1`。 +- URL 带不可猜测的 `/mcp/` 路径,日志只能显示 `/mcp/`。 +- Browser `navigator.modelContext.getTools()` 和 Node MCP `tools/list` 暴露同一批 canonical tools,受 Profile 差异影响。 +- 非 loopback host、错误路径、未知或已删除 `mcp-session-id` 必须被拒绝。 + +## 能力模型 + +| 能力域 | 用户价值 | BDD 证明什么 | +| --- | --- | --- | +| Agentic Chat | 用户能在 IDE 左侧完成 AI 对话和任务控制。 | 启动、输入、发送、stream、停止、历史、恢复和错误可见。 | +| Agentic Layout | AI 工作区成为主视图,但不破坏传统 IDE 工作流。 | Classic/Agentic 切换、Explorer/editor interop、resize bounds、主题恢复。 | +| Context & Commands | 用户可以显式控制 Agent 的上下文和命令意图。 | Slash command、mention、附件、删除 chip、metadata safety。 | +| Permission & Trust | 高风险能力必须可见、可拒绝、可恢复。 | 权限弹窗、badge、permission state、无自动决策工具。 | +| WebMCP / MCP | Agent 和外部客户端能稳定调用 IDE 能力。 | canonical naming、Profile gating、bridge transport、fallback broker。 | +| IDE Capability Groups | Agent 可以安全使用 Workspace/Search/Diagnostics/File/Editor/Terminal。 | 工作区边界、有界响应、full-only mutation、清理。 | +| ACP Node Runtime | 后端会话、线程、协议、配置和错误恢复稳定。 | raw session id、thread pool、permission routing、process config、RPC sync。 | + +## Profile 模型 + +WebMCP 暴露能力由 Profile 控制。Profile 是权限边界,不是展示偏好。 + +| Profile | 应暴露能力 | 不应暴露能力 | 典型用例 | +| --- | --- | --- | --- | +| `default` | IDE 启动、默认 ACP Chat 打开、安全状态读取、Agentic 默认布局、只读布局检查。 | 发送消息、读取历史正文、修改文件、终端命令、调试读写。 | 启动 smoke、安全默认面、fallback。 | +| `interactive` | default 能力,加上会话列表、可用命令、输入发送、历史切换、上下文附件、只读 IDE 工具。 | Full-only 写操作、调试读写、跨会话 relay 发布。 | 真实 Chat 交互和只读集成。 | +| `full` | interactive 能力,加上写入、调试、权限、终端、文件和编辑器可逆变更能力。 | 不带清理逻辑的真实工作区破坏性操作。 | 端到端权限、终端、文件、relay、debug。 | + +本地验证非默认 Profile 时使用 loopback 查询参数: + +```text +http://localhost:8080/?workspaceDir=&webMcpProfile=interactive +http://localhost:8080/?workspaceDir=&webMcpProfile=full +``` + +`opensumi_enable_capability_group` 只能作为目录/发现辅助,不能让 Profile 禁止的工具变得可调用。 + +## 安全与信任模型 + +### Canonical Tool Names + +外部能力工具必须使用 lower-snake canonical 名称: + +| 能力 | 工具名 | +| ----------------- | ------------------------------------ | +| MCP 连接发现 | `opensumi_get_mcp_server_connection` | +| ACP Chat 状态 | `acp_chat_get_session_state` | +| ACP Chat 权限状态 | `acp_chat_get_permission_state` | +| 打开 ACP Chat | `acp_chat_show_chat_view` | +| 会话列表 | `acp_chat_list_sessions` | +| 可用命令 | `acp_chat_get_available_commands` | +| 准备 relay digest | `acp_chat_prepare_session_digest` | +| 发布 relay digest | `acp_chat_post_prepared_relay` | +| 读取有界消息 | `acp_chat_read_session_messages` | +| 切换模式 | `acp_chat_set_session_mode` | +| 文件读取 | `file_read` | +| 文本搜索 | `search_text` | + +不允许作为外部能力出现: + +- `_opensumi/{group}/{action}` 旧标识。 +- `acp_chat_getSessionState` 这类 camelCase 旧名称。 +- `acp_sendMessage`、`acp_createSession`、`acp_switchSession`、`acp_clearSession`、`acp_cancelRequest`、`acp_handlePermissionDialog` 等旧 direct tools。 + +### 内容边界 + +以下工具和状态面必须保持 metadata-only 或有界返回: + +| 表面 | 允许返回 | 不允许返回 | +| --- | --- | --- | +| session state | active session id、状态、loading、permission count、有限 metadata。 | prompt、assistant 正文、工具结果、权限正文。 | +| session list | session id、标题、时间、状态摘要。 | 消息正文、附件原文、工具结果。 | +| permission state | active session id、dialog count、pending count。 | 请求正文、文件内容、选项详情、决策按钮。 | +| relay prepare | metadata、最长 300 字符 preview、长度统计、过期时间。 | 完整 digest、完整源会话内容。 | +| debug read | 显式 `maxMessages`、`maxChars` 边界内的 user/assistant。 | 工具结果、无界历史、secret。 | +| capability describe | group/tool metadata、参数说明。 | workspace 文件正文或 editor buffer 内容。 | + +### Permission + +权限体验必须满足三条底线: + +- full Profile 才能触发写入、relay、debug read、终端命令等高风险能力。 +- 权限决策只能通过可见 UI 完成,不能通过 ACP/WebMCP 后门完成。 +- 取消或拒绝权限后,Chat 必须恢复可用,同一会话能继续发送普通消息。 + +## WebMCP 与 MCP 集成 + +推荐集成流程: + +1. 在浏览器 WebMCP 表面调用 `opensumi_get_mcp_server_connection({})`。 +2. 使用返回的 Streamable HTTP URL 建立 MCP client。 +3. 调用 `tools/list` 检查当前 Profile 工具面。 +4. 直接调用 Profile 已暴露工具。 +5. 如客户端不能直接调用能力工具,可用 `opensumi_invoke_capability_tool({ tool, arguments })` 作为 fallback broker。 + +Fallback broker 应接受: + +```json +{ + "tool": "acp_chat_list_sessions", + "arguments": {} +} +``` + +也应兼容常见嵌套误用,并在缺少 string `tool` 时返回 `INVALID_ARGUMENTS`。 + +## IDE 能力组 + +full Profile 期望覆盖以下 IDE capability groups: + +| Group | 用户/集成方能力 | 关键边界 | +| --- | --- | --- | +| `workspace` | 读取根目录、打开文件、最近工作区 metadata。 | 返回 metadata,不返回文件正文。 | +| `search` | 文件名、文本、符号搜索。 | 结果有数量和片段边界。 | +| `diagnostics` | 读取诊断列表、统计、打开诊断。 | 返回 severity、path、range、message 等 metadata。 | +| `file` | 读取、列出、判断、可逆创建/写入/移动/删除。 | 限制在 workspace 内,拒绝 path traversal。 | +| `editor` | 打开、读取 active editor、读取范围、选择、格式化、保存。 | 读写能力受 Profile 控制。 | +| `terminal` | 读取终端状态,创建终端,运行命令,读取输出。 | 输出有界,写/命令能力只在 full Profile。 | + +终端创建文件后 Explorer 自动刷新是 AI Native IDE 的关键体验:Agent 通过 Terminal 造成的真实工作区变化,必须被 IDE 文件树自动感知。BDD 不能用 `file_create`、`file_write` 或 file-tree service shortcut 代替终端路径。 + +## Node 运行时契约 + +这些场景不直接面向终端用户,但决定产品能否稳定运行。 + +| 契约 | 产品要求 | +| --- | --- | +| ACP Agent Session Lifecycle | raw ACP session id 贯穿 new/load/send/dispose;permission routing、terminal、pool 在 dispose 后清理。 | +| ACP Agent Protocol Client | 协议版本、状态机、notification filtering、entry conversion 和 entry update 稳定。 | +| Thread Pool LRU | pool 大小保持 3;只复用可复用 thread;无可复用 thread 时 fail fast 并输出诊断。 | +| Session Advanced Operations | config、fork、resume、close、model、modes 使用 raw session id,缺失参数在调用连接前失败。 | +| Permission Routing | 只路由已注册 raw session;选项排序稳定;计数/作用域 metadata-only;重复 request 不替换 resolver。 | +| Process Config | command/args/env/node path 按优先级合并;相对 node path fail fast;不突变注册对象。 | +| Client Handlers | file handler 限制在 workspace 内;terminal 由 raw session owner 管理;输出有界且清理幂等。 | +| Chat Session Storage | session list 最多 20;raw id 与 `acp:` 归一;active in-memory session 不被列表加载覆盖。 | +| RPC Bridge and Thread Status | Node 侧复用 browser registry catalog;RPC 成功/失败 class 与 browser 直接执行一致;缺失 client fail fast。 | +| Error and Recovery | 错误归一成可读 Error,保留 SDK code/data;details 有界并 redacts token/key/secret/password。 | + +## 发布准入 + +### P0 不可发布 + +出现任一情况,不应发布 AI Native IDE 能力: + +- default Profile 可执行发送消息、读取历史正文、修改文件、运行终端命令或调试读写。 +- Profile 禁止的工具通过 `opensumi_enable_capability_group`、fallback broker 或旧工具名绕过。 +- state/list/permission/debug/relay/capability describe 泄露 prompt、assistant 正文、工具结果、权限正文或 secret。 +- 权限决策可以被 ACP/WebMCP 自动 approve/reject。 +- Chat 发送、停止、失败、刷新后进入不可恢复 loading 或输入框永久不可用。 +- 会话隔离失效,A session 的流式更新污染 B session。 +- MCP bridge token 出现在日志或 evidence 中。 +- File/editor/terminal 写操作越过 workspace 边界或没有清理路径。 + +### P1 发布风险 + +以下问题可以按版本策略评估,但必须记录风险和补救计划: + +- Debug Log redacted render/copy 合约尚未实现,真实 redaction 审计应标为 `BLOCKED`。 +- 权限弹窗缺少稳定 Reject/close selector,导致 full Profile 权限 UI 无法自动证明。 +- `acp_chat_set_session_mode` 返回了请求的 `modeId`,但 session state 暂不强制暴露 active mode。 +- 部分 interactive/full 场景缺少确定性 fixture,只能标为 `BLOCKED`。 +- 布局压力下存在轻微视觉瑕疵,但不影响 Chat、Explorer、Editor 和 Terminal 可用性。 + +### Readiness Matrix + +| 维度 | 发布目标 | 失败判定 | +| --- | --- | --- | +| 启动可用 | default Profile 下 IDE readiness、Agentic Chat、safe tools 可用。 | shell 未 ready、Chat 不可打开、旧工具出现在默认面。 | +| 核心对话 | interactive 下输入、发送、stream、stop、重试稳定。 | 重复消息、卡 loading、输入框不恢复。 | +| 上下文 | slash command、mention、附件和删除行为可控。 | 删除后仍发送,metadata 泄露内容。 | +| 历史恢复 | 多会话、切换、刷新、复杂响应恢复一致。 | 消息/工具卡重复,跨 session 污染。 | +| 权限 | full 下权限弹窗可见、可拒绝、可恢复。 | 自动决策、计数泄露正文、拒绝后不可继续。 | +| MCP 集成 | bridge 可发现,canonical tools 与 browser/MCP 对齐。 | token 泄露、旧名称可调用、surface 不一致。 | +| IDE 能力 | workspace/search/diagnostics/file/editor/terminal 安全可用。 | path traversal、无界输出、写操作越权。 | +| Node runtime | session、thread、protocol、process、RPC、error recovery 稳定。 | phantom session、pool hang、错误不可恢复。 | + +## BDD 覆盖矩阵 + +| 场景 | Layer | Profile | 产品意义 | +| --- | --- | --- | --- | +| `bdd-runtime-preflight.scenario.md` | `runtime-ui` | `default` | BDD 执行前确认 IDE readiness、browser/MCP 执行面和诊断脱敏。 | +| `acp-chat.scenario.md` | `runtime-ui` | `default` | 默认 ACP Chat smoke 和安全状态读取。 | +| `acp-chat-agentic-startup.scenario.md` | `runtime-ui` | `default` | Agentic 默认布局、左侧 Chat、默认安全工具面。 | +| `acp-chat-agentic-fallback.scenario.md` | `runtime-ui` | `default` | ACP 后端不可用时仍有可用 Chat surface。 | +| `acp-layout-switch.scenario.md` | `runtime-ui` | `default` | Classic/Agentic 切换、Explorer interop、resize bounds。 | +| `acp-chat-agentic-theme-persistence.scenario.md` | `runtime-ui` | `default` | 主题、布局偏好、尺寸刷新后恢复。 | +| `acp-chat-agentic-input-send.scenario.md` | `runtime-ui` | `interactive` | 草稿、首发、命令、mention、附件、滚动和失败恢复。 | +| `acp-chat-agentic-stream-rendering.scenario.md` | `runtime-ui` | `interactive` | 确定性 stream 中的正文、推理、计划、工具卡片和恢复。 | +| `acp-chat-agentic-cancel-stop.scenario.md` | `runtime-ui` | `interactive` | 长响应停止/取消、输入恢复和后续发送。 | +| `acp-chat-agentic-reload-during-stream.scenario.md` | `runtime-ui` | `interactive` | 流式过程中刷新页面后的可用恢复。 | +| `acp-chat-agentic-history.scenario.md` | `runtime-ui` | `interactive` | New Chat、历史列表、会话切换和权限 badge。 | +| `acp-chat-agentic-session-isolation.scenario.md` | `runtime-ui` | `interactive` | 多会话并发状态和 stream 更新隔离。 | +| `acp-chat-agentic-rich-history-restore.scenario.md` | `runtime-ui` | `interactive` | 复杂响应在切换/刷新后不丢不重。 | +| `acp-chat-agentic-context-attachments.scenario.md` | `runtime-ui` | `interactive` | 文件、文件夹、代码、规则上下文 chip 和附件清理。 | +| `acp-chat-agentic-command-surface.scenario.md` | `runtime-ui` | `interactive` | Slash command 发现、选择、取消、发送和 metadata parity。 | +| `acp-chat-agentic-layout-interop.scenario.md` | `runtime-ui` | `interactive` | Agentic Chat 与 Explorer/editor 的常规互操作。 | +| `acp-chat-agentic-layout-stress.scenario.md` | `runtime-ui` | `interactive` | 长内容、工具结果、scroll、resize 和布局往返稳定。 | +| `acp-chat-agentic-keyboard-a11y.scenario.md` | `runtime-ui` | `interactive` | 键盘无鼠标路径、focus、Escape 和工具卡片操作。 | +| `acp-chat-agentic-error-taxonomy.scenario.md` | `runtime-ui` | `interactive` | create/load/send/auth/disconnected/config 失败分类与重试。 | +| `acp-chat-agentic-config-controls.scenario.md` | `runtime-ui` | `full` | Mode、Model、Config 控件和 stream 中安全 gating。 | +| `acp-chat-agentic-permission-during-send.scenario.md` | `runtime-ui` | `full` | 发送中权限弹窗、badge、dismiss 和恢复。 | +| `acp-chat-agentic-debug-log-from-chat.scenario.md` | `runtime-ui` | `full` | Chat stream 后打开 Debug Log 并关联日志。 | +| `permission-dialog.scenario.md` | `runtime-ui` | `full` | 权限状态和弹窗可观察,不通过工具自动决策。 | +| `terminal-file-tree-refresh.scenario.md` | `runtime-ui` | `full` | Terminal 创建/删除文件后 Explorer 自动刷新。 | +| `acp-debug-log.scenario.md` | `runtime-ui` | `full` | Debug Log store、viewer、条数上限、copy/clear 和 blocked redaction audit。 | +| `available-commands.scenario.md` | `mcp-contract` | `interactive/full` | Command metadata 通过 profile-granted `acp_chat` 暴露。 | +| `webmcp-capability-surface.scenario.md` | `mcp-contract` | `interactive/full` | Browser 和 MCP surfaces 暴露同一批 canonical tools。 | +| `acp-mcp-bridge.scenario.md` | `mcp-contract` | `default/interactive/full` | MCP bridge startup、injection、catalog、profiles 和 transport。 | +| `session-mode.scenario.md` | `mcp-contract` | `full` | Session mode 切换返回合约和 metadata-only state。 | +| `session-relay.scenario.md` | `mcp-contract` | `full` | 跨会话 digest relay、权限门禁和有界 debug read。 | +| `error-handling.scenario.md` | `mcp-contract` | `full` | Capability boundaries、invalid inputs 和 redacted structured errors。 | +| `webmcp-ide-capability-groups.scenario.md` | `mcp-contract` | `full` | Workspace/Search/Diagnostics/File/Terminal/Editor groups。 | +| `acp-agent-session-lifecycle.scenario.md` | `node-contract` | `default` | Node session lifecycle、stream、cancel、dispose 和 pool cleanup。 | +| `acp-agent-protocol-client.scenario.md` | `node-contract` | `default` | ACP protocol handshake、状态机、entry conversion 和 isolation。 | +| `acp-thread-pool-lru.scenario.md` | `node-contract` | `default` | Thread pool LRU recycling、evicted reload、race handling。 | +| `acp-session-advanced-operations.scenario.md` | `node-contract` | `default` | Config、fork、resume、close、model、modes 合约。 | +| `acp-process-config.scenario.md` | `node-contract` | `default` | Browser config merge 和 Node spawn config resolution。 | +| `acp-client-handlers.scenario.md` | `node-contract` | `default` | ACP client file/terminal handlers 的 workspace/session scope。 | +| `acp-chat-session-storage.scenario.md` | `node-contract` | `default` | Session provider、activation、fallback、command propagation、cleanup。 | +| `acp-rpc-bridge-and-status.scenario.md` | `node-contract` | `default` | Browser/Node WebMCP RPC definitions 和 thread status 同步。 | +| `acp-permission-routing.scenario.md` | `node-contract` | `full` | Node permission routing 和 browser permission bridge 生命周期。 | +| `acp-error-and-recovery.scenario.md` | `node-contract` | `full` | Node/MCP/UI 错误归一、脱敏和恢复。 | + +## BDD 评审规则 + +一个合适的 BDD 用例应满足: + +- 明确声明 `Layer`、`Required profile`、`Fixtures`、`Workspace mutation`、`Automation status`。 +- 用例层级和验证对象一致:UI 用 `runtime-ui`,工具目录/Profile/错误/transport 用 `mcp-contract`,服务/线程/协议/配置/handler 用 `node-contract`。 +- 用户可见行为或外部合约清晰,不只是实现细节。 +- 依赖 deterministic fixture,不依赖真实 LLM 生成文本。 +- 对 workspace mutation 有明确范围和清理步骤。 +- Pass、Blocked、Fail 边界明确。 +- 不使用已废弃 ACP direct tools 作为正常操作路径。 +- 对敏感数据有不泄露断言。 + +不合适或需要修正的信号: + +- 在 default Profile 中要求 full-only 工具。 +- 把没有 fixture 的真实 agent 长流程当成稳定断言。 +- 断言 prompt、assistant 正文或 tool result 出现在 state/list/permission 工具返回里。 +- 用旧工具名作为正向路径,例如 `acp_sendMessage` 或 `_opensumi/file/read`。 +- UI 场景没有稳定 selector,却把执行失败记为 `FAIL`,而不是 `BLOCKED`。 +- 把多个无关功能塞进一个场景,导致失败原因无法定位。 +- 有 workspace mutation 但没有清理。 +- 只验证工具返回 `success: true`,没有验证 Profile 边界、内容边界或用户可见结果。 + +## Pass / Blocked / Fail + +| 结果 | 使用条件 | +| --- | --- | +| `PASS` | 声明的 Profile、fixture、执行面都存在,并且所有关键行为满足契约。 | +| `BLOCKED` | 缺少 Profile、fixture、MCP bridge、browser ModelContext、稳定 selector 或运行环境,导致场景无法开始或无法证明。 | +| `FAIL` | 前置条件存在,但产品行为违反契约,例如泄露内容、工具名漂移、UI 卡死、Profile 越权、无法恢复。 | + +不要把缺少 interactive/full Profile 的场景标成部分通过。当前规范要求跳过或标为 `BLOCKED`,并说明缺少的前置条件。 + +## 新增场景建议 + +新增或变更 BDD 用例时,按这个顺序评审: + +1. 它证明的是用户可见行为、外部集成合约,还是内部服务合约。 +2. 它应该属于 `runtime-ui`、`mcp-contract` 还是 `node-contract`。 +3. 它是否使用最小 Profile,能 default 就不要 full。 +4. 它是否有确定性 fixture,尤其不要断言真实 LLM 文本。 +5. 它是否包含内容泄露断言,尤其是 state/list/permission/debug/relay。 +6. 它是否只使用 canonical tool names,并验证旧别名被拒绝。 +7. 它是否有可逆 mutation、受控路径和结束清理。 +8. 它的 PASS/BLOCKED/FAIL 是否能让失败原因被定位。 + +如果一个用例能被这 8 条清楚解释,它通常就是合适的。如果解释不清,优先拆分用例或补 fixture,而不是把更多步骤塞进同一个场景。 diff --git a/test/bdd/README.md b/test/bdd/README.md new file mode 100644 index 0000000000..746b68c472 --- /dev/null +++ b/test/bdd/README.md @@ -0,0 +1,315 @@ +# ACP BDD Suite + +This folder contains BDD scenarios and contract specs for the ACP module, ACP Chat, and the current WebMCP capability surface. + +The suite is intentionally split by execution layer. Do not treat every `.scenario.md` as a browser-only UI test. + +## Common Preflight + +Runtime scenarios use Chrome DevTools MCP against the IDE dev server: + +```text +http://localhost:8080/?workspaceDir= +``` + +The page is ready when Chrome DevTools MCP evaluation confirms that the IDE shell is ready and at least one stable workbench signal is visible: + +```js +const text = document.body.innerText || ''; +const shellReady = + document.readyState === 'complete' && + !!document.querySelector('#main') && + !document.querySelector('.loading_indicator'); +const workbenchVisible = + text.includes('EXPLORER') || + text.includes('Agentic') || + text.includes('editor.js') || + !!document.querySelector('.monaco-editor'); +shellReady && workbenchVisible; +``` + +`EXPLORER` remains a useful Explorer-specific signal, but it is not the only valid readiness marker for Agentic-first layouts. Chrome DevTools MCP is used for browser startup, DOM readiness, UI interaction, and dialog observability. ACP tool execution uses the current OpenSumi MCP bridge when a scenario explicitly requires MCP transport. `navigator.modelContext` remains a supported WebMCP surface for browser runtime checks and is validated against MCP only where a scenario explicitly compares browser and MCP tool exposure. + +## Scenario Layers + +| Layer | Purpose | Execution expectation | +| --- | --- | --- | +| `runtime-ui` | Real IDE rendering, layout, dialogs, input, history, and visible recovery. | Run Common Preflight, then use Chrome DevTools MCP plus MCP calls only when the scenario requires them. | +| `mcp-contract` | WebMCP/MCP tool names, profile gating, catalog shape, bounded responses, and error contracts. | Use fresh MCP transport sessions; browser UI is needed only for observable dialog or surface parity checks. | +| `node-contract` | ACP service, thread, process, RPC, handler, storage, and debug-log behavior. | Run deterministic service/unit-contract fixtures; browser interaction is optional unless the scenario says otherwise. | +| `exploratory/manual` | Historical investigations, issue notes, and evidence reports. | Not part of the required `.scenario.md` suite; keep these as `.md`, `.json`, or image evidence files. | + +Each `.scenario.md` must declare: + +- `Layer` +- `Required profile` +- `Fixtures` +- `Workspace mutation` +- `Automation status` + +Node/service scenarios are contract specs. They do not need to prove behavior by clicking through the browser unless the scenario explicitly includes a runtime UI assertion. + +## Profile Matrix + +| Profile | Expected coverage | Result rule | +| --- | --- | --- | +| `default` | Common Preflight, default ACP Chat smoke, default safe state tools, Agentic startup, fallback, and read-only layout checks. | Default-profile scenarios should PASS or FAIL. Do not mark interactive/full-only work as PARTIAL in a default run; skip scheduling it or mark it BLOCKED with the missing profile. | +| `interactive` | Default coverage plus profile-granted read/UI tools such as `acp_chat_list_sessions`, `acp_chat_get_available_commands`, `acp_chat_prepare_session_digest`, and IDE read groups. | Interactive scenarios should PASS/FAIL only when the profile is active and required fixtures exist. | +| `full` | Interactive coverage plus profile-granted write/debug tools such as `acp_chat_set_session_mode`, `acp_chat_post_prepared_relay`, `acp_chat_read_session_messages`, and reversible file/editor/terminal mutation checks. | Full-profile scenarios are BLOCKED, not PARTIAL, when the run lacks full profile, controlled sessions, or stable selectors. | + +Use a profile-specific loopback URL when a local BDD run needs a non-default WebMCP profile. The query override is runtime-only, only applies on local loopback hosts, and does not write the user's saved `ai.native.webmcp.profile` preference: + +```text +http://localhost:8080/?workspaceDir=&webMcpProfile=interactive +http://localhost:8080/?workspaceDir=&webMcpProfile=full +``` + +`PASS` means all required steps for the declared profile ran and met the assertions. `BLOCKED` means the scenario could not start because a declared prerequisite was unavailable. `FAIL` means the declared prerequisites were present but behavior violated the contract. + +## Live Agent BDD Lane + +Runtime-ui scenarios may run against a real LLM-backed ACP agent when the declared profile is available and the goal is live integration coverage. A live-agent run may verify stable outer contracts such as input focus/send, user row creation, streaming/loading state, stop/cancel visibility, reload recovery, permission dialog observability, safe metadata-only state tools, and absence of legacy ACP tools. + +Live-agent runs must not assert assistant text, exact token or chunk timing, generated reasoning/content, tool arguments/results chosen by the model, or exact command/history titles derived from prompts. Treat model output as evidence only after redacting secrets, prompt bodies, full assistant content, permission content, API keys, MCP tokens, raw ACP JSON, and tool results. + +A live-agent pass is normally `PASS` with hardening verdict `DEFER`. Convert to Playwright CI only when the scenario is backed by deterministic ACP provider/protocol fixtures, recorded stable protocol fixtures, or stable selectors and bounded data independent of LLM generation. + +If a scenario supports both live-agent and deterministic execution, record the mode in evidence as `Execution mode: live-agent` or `Execution mode: deterministic-fixture`. When live-agent execution covers only the stable shell contract but not fixture-only assertions, record the omitted fixture assertions explicitly instead of marking them as passed. + +## Evidence Report + +Runtime BDD runs may save local, gitignored evidence under: + +```text +test/bdd/evidence/// +``` + +Use evidence reports for scenarios whose result is hard to review from a stack trace alone: Agentic layout, permission dialogs, streaming state, ACP Debug Log proof, WebMCP/MCP tool exposure, or live-agent shell contracts. Evidence is opt-in for hardened Playwright tests; set `OPENSUMI_BDD_EVIDENCE=1` to write artifacts. `OPENSUMI_BDD_EVIDENCE_DIR=` may override the default root for local experiments. + +Each evidence report should map scenario requirements to critical points: + +- `evidence.json` is the machine-readable summary: scenario metadata, profile, execution mode, critical points, artifact list, scenario verdict, and hardening verdict. +- `report.md` is the human-readable review summary. +- Supporting artifacts may include screenshots, DOM geometry/text snapshots, MCP/WebMCP JSON, bounded ACP Debug Log proof records, and redacted console diagnostics. + +Critical points should be independently verifiable. A `PASS` critical point needs at least one concrete evidence file unless it is a purely in-process assertion already captured in `report.md`. A `BLOCKED` critical point should name the missing profile, fixture, selector, transport, or runtime surface. A `FAIL` critical point should point to the smallest proof showing actual versus expected behavior. + +Do not commit evidence artifacts. Do not store MCP bridge tokens, API keys, raw prompt bodies, full assistant content, permission content, ACP raw payloads containing secrets, or unbounded tool results. Save redacted or bounded metadata instead. + +## Deterministic ACP Agent Fixture + +When an ACP BDD scenario asks for a deterministic ACP provider, use the process-level mock ACP agent unless that scenario explicitly names a more specialized fixture. The mock agent speaks the real ACP stdio/JSON-RPC transport through `AcpThread`, so it exercises process spawn, protocol initialization, session updates, permission routing, debug logging, WebMCP injection, and browser state through the normal product path. + +`acp-chat-agentic-fallback` is not a mock ACP agent fixture. Use the local-loopback browser runtime fixture instead: + +```text +http://localhost:8080/?workspaceDir=&aiNative=true&aiPanelLayout=agentic&acpBddBackendReadyFailure=reject +``` + +The fixture is ignored unless the page is on a loopback host and `aiNative=true` is present. It forces the ACP readiness checkpoint to reject before ACP chat initialization so Agentic fallback rendering can be validated without a real ACP session. + +Configure the ACP agent command with `test/bdd/fixtures/acp-agent/mock-acp-agent.mjs`: + +```json +{ + "ai.native.agent.defaultType": "claude-agent-acp", + "ai-native.acp.agents": { + "claude-agent-acp": { + "command": "node", + "args": ["test/bdd/fixtures/acp-agent/mock-acp-agent.mjs", "--fixture=stream-rich"], + "streaming": true, + "description": "OpenSumi BDD mock ACP agent" + } + } +} +``` + +The fixture can be selected either with `--fixture=` or `OPENSUMI_ACP_BDD_FIXTURE`. Use this mapping for current BDD scenarios: + +| Fixture | Primary BDD use | +| --- | --- | +| `stream-rich` | First send, command metadata, config controls, stream rendering, tool-card rendering, debug-log-from-chat, and normal retry recovery. | +| `long-stream` | Stop/cancel, reload-during-stream, active-stream layout, and active-session isolation checks. | +| `permission` | Permission dialog, active permission badge/count, browser-only dismissal, and permission routing observability. | +| `send-failure` | Send failure recovery after a user row exists. | +| `create-failure` | Create-session failure UI and service recovery. | +| `load-failure` | History/session reload failure and `loadSessionOrNew` recovery. | +| `auth-required` | Auth-required status/error recovery without relying on live credentials. | +| `config-failure` | Footer config error and retry behavior. | +| `process-exit` | Agent process/stdio disconnect recovery while `session/prompt` is pending. | +| `history` | Multi-session history/list/switching, seeded session metadata, and bounded rich replay updates on `session/load`. | + +If a scenario needs more than one fixture class, run the subcases as separate deterministic fixture passes and record the fixture used for each pass in evidence. Do not mix these deterministic fixture assertions with live-agent assertions in a single PASS unless every fixture-only assertion actually ran. + +## Tool Names + +The canonical WebMCP tool name is the only external capability identifier. Each tool is registered once in the browser `WebMcpGroupRegistry` with `tool.name`, and both supported surfaces expose that same name: + +- Browser: `navigator.modelContext.getTools()` / `registerTool()` +- Node: the built-in MCP `opensumi-ide` server `tools/list` / `tools/call` + +Examples: + +| Group | Canonical tool name | +| ---------- | --------------------------------- | +| `file` | `file_read` | +| `search` | `search_text` | +| `acp_chat` | `acp_chat_get_session_state` | +| `acp_chat` | `acp_chat_get_permission_state` | +| `acp_chat` | `acp_chat_show_chat_view` | +| `acp_chat` | `acp_chat_list_sessions` | +| `acp_chat` | `acp_chat_get_available_commands` | +| `acp_chat` | `acp_chat_prepare_session_digest` | +| `acp_chat` | `acp_chat_post_prepared_relay` | +| `acp_chat` | `acp_chat_read_session_messages` | +| `acp_chat` | `acp_chat_set_session_mode` | + +There is no alias or fallback external name for capability tools. Legacy `_opensumi/{group}/{action}` identifiers and camelCase ACP Chat names must not appear in `navigator.modelContext`, MCP `tools/list`, catalog descriptions, or fallback broker calls. BDD may mention them only in explicit negative tests that prove they are rejected. Catalog helpers may temporarily accept old helper spellings for backward compatibility, but scenarios should call and assert the lower-snake canonical helper names. + +Current MCP exposure: + +- Default discovery: `opensumi_get_mcp_server_connection` +- Default profile: `acp_chat_get_session_state`, `acp_chat_get_permission_state`, `acp_chat_show_chat_view` +- Interactive/full profile: profile-granted read tools such as `acp_chat_list_sessions`, `acp_chat_get_available_commands`, and `acp_chat_prepare_session_digest` +- Full profile only: profile-granted write/debug tools such as `acp_chat_read_session_messages`, `acp_chat_set_session_mode`, and `acp_chat_post_prepared_relay` + +The active WebMCP profile is the permission boundary for tool exposure. `opensumi_enable_capability_group` is retained as a catalog/discovery helper for agents and clients that want an explicit group acknowledgement, but BDD scenarios must not require it before invoking tools already allowed by the active profile. Profile-forbidden tools must remain absent from `tools/list` or fail with a structured boundary error even if a group helper has been called. + +## MCP Helper + +For browser-backed BDD runs, first discover the loopback MCP endpoint through the default browser WebMCP surface, then connect a standard MCP client to the returned Streamable HTTP URL: + +```js +const connectionResult = await navigator.modelContext.executeTool('opensumi_get_mcp_server_connection', {}); +const { url, redactedUrl } = connectionResult.result; +// Use `url` only for the MCP client. Use `redactedUrl` in evidence/logs. +const transport = new StreamableHTTPClientTransport(new URL(url)); +await mcp.connect(transport); +``` + +Use the MCP client connected to the IDE's `opensumi-ide` server. Scenario steps refer to this shape: + +```js +await mcp.callTool({ name: 'opensumi_discover_capabilities', arguments: { task: 'acp chat state' } }); +await mcp.callTool({ name: 'acp_chat_get_session_state', arguments: {} }); +``` + +Calling `opensumi_enable_capability_group` is optional for profile-granted tools and should be treated as a catalog helper, not a permission grant. If the client cannot call a profile-exposed capability tool directly, call through the fallback broker: + +```js +await mcp.callTool({ + name: 'opensumi_invoke_capability_tool', + arguments: { + tool: 'acp_chat_list_sessions', + arguments: {}, + }, +}); +``` + +The fallback broker must also tolerate common accidental nesting from agents and normalize it to the target tool's real arguments: + +```js +await mcp.callTool({ + name: 'opensumi_invoke_capability_tool', + arguments: { + tool: 'acp_chat_list_sessions', + arguments: { + arguments: {}, + }, + }, +}); + +await mcp.callTool({ + name: 'opensumi_invoke_capability_tool', + arguments: { + arguments: { + tool: 'acp_chat_list_sessions', + arguments: {}, + }, + }, +}); +``` + +If `tool` is missing or is not a string, the broker should return a structured invalid-arguments failure that points callers back to `{ tool: string, arguments?: object }`. + +Startup logs for the built-in `opensumi-ide` MCP server must not print the full bridge URL or token. A log may include host and port, but the MCP token path must be redacted as `/mcp/`. + +## Scope Rules + +- Do not use `acp_sendMessage`, `acp_createSession`, `acp_switchSession`, `acp_clearSession`, `acp_cancelRequest`, or `acp_handlePermissionDialog`. They are intentionally not registered in the current `acp_chat` group. +- Permission scenarios must observe pending permission state and DOM, but must not approve or reject permission through an ACP tool. +- Runtime permission dismissal must use Chrome DevTools MCP to click a visible Reject or close control. If no stable selector exists, mark the scenario BLOCKED with `missing stable permission dialog selector`. +- Session-mode scenarios must verify that a successful mode switch is observable through session state. A response from `acp_chat_set_session_mode` alone is not enough. +- ACP Chat state/list responses may expose bounded, user-visible session title metadata such as `title` or `sourceTitle`, even when that title is derived from the first prompt. Do not treat those title fields as prompt-content leakage. +- ACP Chat scenarios must not assert full prompt/message bodies, assistant response text, tool-call arguments/results, raw ACP payloads, file contents, secrets, or permission content in `acp_chat_get_session_state`, `acp_chat_list_sessions`, or permission state responses. +- File/editor/terminal BDD belongs to those capability groups, not to ACP Chat. + +## Current Scenarios + +| Scenario | Layer | Required profile | Focus | +| --- | --- | --- | --- | +| `bdd-runtime-preflight.scenario.md` | `runtime-ui` | `default` | Browser readiness, ModelContext/MCP bridge availability, and blocked-run diagnostics. | +| `acp-chat.scenario.md` | `runtime-ui` | `default` | Default ACP Chat smoke and safe state observability. | +| `acp-chat-agentic-startup.scenario.md` | `runtime-ui` | `default` | Agentic startup, default layout, safe tool surface, and metadata-only state. | +| `acp-chat-agentic-side-entry-filter.scenario.md` | `runtime-ui` | `default` | Agentic side entry filter showing only Explorer and Git while Classic remains unchanged. | +| `acp-chat-agentic-fallback.scenario.md` | `runtime-ui` | `default` | Usable Agentic chat surface when ACP backend readiness fails. | +| `acp-layout-switch.scenario.md` | `runtime-ui` | `default` | Agentic/Classic switching, Explorer interop, resize bounds, and read-only state checks. | +| `acp-chat-agentic-input-send.scenario.md` | `runtime-ui` | `interactive` | Draft input, first send, commands, mentions, attachments, scroll, and recovery. | +| `acp-chat-agentic-stream-rendering.scenario.md` | `runtime-ui` | `interactive` | Deterministic ACP Agent stream rendering for content, reasoning, plan, tool calls, session state, completion, and recovery. | +| `acp-chat-agentic-deep-thinking-collapse.scenario.md` | `runtime-ui` | `interactive` | Deep Thinking default collapse, streaming expansion, explicit toggle state, and metadata-only state checks. | +| `acp-chat-agentic-cancel-stop.scenario.md` | `runtime-ui` | `interactive` | Long-stream stop/cancel behavior, input recovery, and follow-up send. | +| `acp-chat-agentic-rich-history-restore.scenario.md` | `runtime-ui` | `interactive` | Complex content, reasoning, plan, and tool-call history restore across switching and reload. | +| `acp-chat-agentic-permission-during-send.scenario.md` | `runtime-ui` | `full` | Permission dialog, badge, dismissal, and recovery during an active Agentic send. | +| `acp-chat-agentic-session-isolation.scenario.md` | `runtime-ui` | `interactive` | Concurrent session status, stream updates, and history selection isolation. | +| `acp-chat-agentic-config-controls.scenario.md` | `runtime-ui` | `full` | Footer `configOptions` controls with deterministic ACP `stream-rich` fixture coverage for mode, model, thought level, boolean values, returned-state refresh, send-time snapshots, and safe state-summary checks. | +| `acp-chat-agentic-context-attachments.scenario.md` | `runtime-ui` | `interactive` | File, folder, code, and rule context chips, attachment cleanup, and metadata safety. | +| `acp-chat-agentic-command-surface.scenario.md` | `runtime-ui` | `interactive` | Slash command discovery, selection, cancellation, send, and metadata parity. | +| `acp-chat-agentic-reload-during-stream.scenario.md` | `runtime-ui` | `interactive` | Page reload while streaming and recovery to a usable Agentic chat state. | +| `acp-chat-agentic-error-taxonomy.scenario.md` | `runtime-ui` | `interactive` | Create, load, send, auth, disconnected, and config failure visibility and retry. | +| `acp-chat-agentic-layout-stress.scenario.md` | `runtime-ui` | `interactive` | Long content, tool results, scrolling, resizing, and layout round-trip stability. | +| `acp-chat-agentic-keyboard-a11y.scenario.md` | `runtime-ui` | `interactive` | Keyboard-only input, commands, history, dialogs, and tool-card interaction. | +| `acp-chat-agentic-debug-log-from-chat.scenario.md` | `runtime-ui` | `full` | Debug log viewer correlation and controls after a chat stream; redaction audit is blocked until product support exists. | +| `acp-chat-agentic-theme-persistence.scenario.md` | `runtime-ui` | `default` | Theme, Agentic layout preference, geometry, and visual usability persistence. | +| `acp-chat-agentic-history.scenario.md` | `runtime-ui` | `interactive` | New Chat, persisted history, session switching, and permission badges. | +| `acp-chat-agentic-layout-interop.scenario.md` | `runtime-ui` | `interactive` | Explorer/editor interop, resize, reload, and Agentic/Classic round trip. | +| `available-commands.scenario.md` | `mcp-contract` | `interactive/full` | Command metadata through profile-granted `acp_chat`. | +| `webmcp-capability-surface.scenario.md` | `mcp-contract` | `interactive/full` | Browser and MCP surfaces expose the same canonical tool names. | +| `acp-mcp-bridge.scenario.md` | `mcp-contract` | `default/interactive/full` | Built-in MCP bridge startup, injection, catalog, profiles, and profile-gated exposure. | +| `session-mode.scenario.md` | `mcp-contract` | `full` | Full-profile mode switching return contract plus metadata-only state reads. | +| `session-relay.scenario.md` | `mcp-contract` | `full` | Cross-session digest relay, permission gate, and bounded debug reads. | +| `permission-dialog.scenario.md` | `runtime-ui` | `full` | Permission state and dialog observability without ACP decision tools. | +| `error-handling.scenario.md` | `mcp-contract` | `full` | Capability boundaries, invalid inputs, and redacted structured errors. | +| `webmcp-ide-capability-groups.scenario.md` | `mcp-contract` | `full` | Workspace, search, diagnostics, file, terminal, and editor groups. | +| `terminal-file-tree-refresh.scenario.md` | `runtime-ui` | `full` | Terminal-created and terminal-deleted files refresh Explorer automatically. | +| `acp-agent-session-lifecycle.scenario.md` | `node-contract` | `default` | Node-side session creation, loading, streaming, cancellation, disposal, and pool cleanup. | +| `acp-session-advanced-operations.scenario.md` | `node-contract` | `default` | Config option, fork, resume, close, model selection, and available-mode operations. | +| `acp-thread-pool-lru.scenario.md` | `node-contract` | `default` | Thread-pool LRU recycling, evicted-session reload, race handling, and failure diagnostics. | +| `acp-agent-protocol-client.scenario.md` | `node-contract` | `default` | ACP protocol handshake, status machine, notification filtering, and entry conversion. | +| `acp-permission-routing.scenario.md` | `node-contract` | `full` | Node permission routing and browser permission bridge lifecycle. | +| `acp-process-config.scenario.md` | `node-contract` | `default` | Browser config merge and node spawn config resolution. | +| `acp-client-handlers.scenario.md` | `node-contract` | `default` | ACP client file and terminal handlers exposed to the agent process. | +| `acp-chat-session-storage.scenario.md` | `node-contract` | `default` | Browser chat session provider, activation, fallback, command propagation, and permission cleanup. | +| `acp-debug-log.scenario.md` | `runtime-ui` | `full` | Protocol trace store, entry bounds, raw viewer controls, and blocked redaction audit. | +| `acp-error-and-recovery.scenario.md` | `node-contract` | `full` | Structured failures and recovery across node, MCP, and browser UI boundaries. | +| `acp-rpc-bridge-and-status.scenario.md` | `node-contract` | `default` | Browser/node WebMCP RPC definitions, execution, and thread status synchronization. | + +## Evidence and Reports + +- Keep runtime screenshots, JSON captures, and dated reports in `test/bdd` or a dated evidence subdirectory. +- Evidence files are not required scenarios and should not be listed in Current Scenarios. +- Historical reports may use older scenario names. New runs should reference the split Agentic scenario files. + +## Deleted or Split Scenarios + +The following scenarios were removed because they target capabilities that are no longer part of the ACP Chat runtime contract: + +- `message-flow.scenario.md`: required `acp_sendMessage`. +- `cancel-request.scenario.md`: required `acp_cancelRequest`. +- `session-lifecycle.scenario.md`: required create/switch/clear session tools. +- `file-operations.scenario.md`: belongs to the file capability group, not ACP. +- `chat-view.scenario.md`: covered by `acp_chat_show_chat_view`. +- `regression-core.scenario.md`: mixed unrelated groups and old direct tools. +- `background-permission-notification.scenario.md`: required old permission tools. +- `acp-agent-path-config.scenario.md`: not observable through ACP Chat WebMCP. + +`acp-chat-agentic-layout.scenario.md` was split into focused Agentic startup, input/send, history, layout interop, and fallback scenarios. The non-scenario index is `acp-chat-agentic-layout.md`. diff --git a/test/bdd/acp-agent-protocol-client.scenario.md b/test/bdd/acp-agent-protocol-client.scenario.md new file mode 100644 index 0000000000..9dd558de22 --- /dev/null +++ b/test/bdd/acp-agent-protocol-client.scenario.md @@ -0,0 +1,81 @@ +# Scenario: ACP Agent Protocol Client - Handshake, Status, Entries, Notifications + +**Trigger:** `packages/ai-native/src/node/acp/acp-thread.ts` + +**Layer:** `node-contract` **Required profile:** `default` **Fixtures:** The mock ACP agent at `node test/bdd/fixtures/acp-agent/mock-acp-agent.mjs` provides a real stdio ACP protocol process with controllable `stream-rich`, `long-stream`, `send-failure`, `load-failure`, `auth-required`, and `permission` responses/notifications. Unsupported protocol, early process exit, and file/terminal client-hook subcases still require specialized process fixtures if the mock agent does not drive those hooks. **Workspace mutation:** None. **Automation status:** Automated contract spec; no browser click path is required. + +## Given + +- An `AcpThread` is created with a valid `AgentProcessConfig`. +- The spawned agent speaks ACP protocol version `1`. +- The test harness can observe thread events, status changes, and ACP debug-log lines from the mock process. + +## When + +### Part A - Initialize + +1. Call `thread.initialize(config)`. +2. The thread spawns the configured process. +3. The thread creates an ACP `ClientSideConnection` over stdio. +4. The thread sends client capabilities for file read/write and terminal. +5. The thread receives `InitializeResponse`. +6. Repeat initialization with an agent that exits before responding. +7. Repeat initialization with an unsupported future protocol version. + +### Part B - Session Binding + +8. Call `newSession({ cwd, mcpServers })`. +9. Call `loadSession({ sessionId, cwd, mcpServers })` for an existing session. +10. Call `loadSessionOrNew` with a missing session id. + +### Part C - Prompt And Notification Handling + +11. Call `prompt({ sessionId, prompt })`. +12. Emit ACP session notifications for: + - user message chunks + - assistant message chunks + - tool call updates + - plan updates +13. Emit an update for an existing entry id. +14. Emit malformed or unknown notification payloads. +15. Emit a notification for a different session id. +16. Mark the final assistant message complete. + +### Part D - Tool And Permission Hooks + +17. Agent calls client `readTextFile` when a handler-driving process fixture is available. +18. Agent calls client `writeTextFile` when a handler-driving process fixture is available. +19. Agent calls client terminal create/output/wait/kill/release when a handler-driving process fixture is available. +20. Agent calls client `requestPermission` through the mock ACP agent `--fixture=permission`. + +### Part E - Process Exit And Reset + +21. Agent process writes stderr lines while connected. +22. Agent process exits. +23. Call `reset` before reusing the thread. +24. Call `dispose`. + +## Then + +- Part A sets `initialized=true`, `isConnected=true`, and stores `agentCapabilities`. +- If the agent exits before initialize completes, initialization fails with a normalized command/startup error and leaves `isConnected=false`. +- If the agent reports a future unsupported protocol version, initialization fails before creating a session. +- `newSession` and `loadSession` set the raw ACP `sessionId`, set `needsReset=true`, and transition to `awaiting_prompt`. +- `mcpServers` passed to `newSession` and `loadSession` preserve names, urls, and headers without duplication. +- `loadSessionOrNew` falls back to `newSession` only after load failure. +- `prompt` transitions to `working` before the agent call and back to `awaiting_prompt` after completion. +- Session notifications for non-current sessions do not mutate entries. +- User, assistant, tool call, and plan notifications produce typed thread entries in arrival order. +- Updates for an existing entry id emit `entry_updated` instead of adding duplicate rows. +- Malformed or unknown notification payloads are logged or ignored without crashing the thread. +- Permission requests are routed through the permission routing service using the current raw session id. +- File and terminal client hooks delegate to ACP handlers and surface handler errors as agent-call errors. +- ACP stdout/stderr lines are recorded in the debug log with direction, agent id, thread id, and session id when known. +- Process exit sets `isProcessRunning=false`, `isConnected=false`, and `threadStatus=disconnected`. +- `reset` clears entries/session binding enough for safe thread reuse. +- `dispose` kills the process and releases event resources. + +## Pass / Fail Judgment + +- **PASS** - the thread behaves as a protocol-safe ACP client with observable status and entry state; handler-hook subcases pass only when their specialized process fixture runs. +- **FAIL** - unsupported protocol versions are accepted, foreign session notifications mutate state, or process exit leaves the thread appearing connected. diff --git a/test/bdd/acp-agent-session-lifecycle.scenario.md b/test/bdd/acp-agent-session-lifecycle.scenario.md new file mode 100644 index 0000000000..9471d742db --- /dev/null +++ b/test/bdd/acp-agent-session-lifecycle.scenario.md @@ -0,0 +1,73 @@ +# Scenario: ACP Agent Session Lifecycle - Create, Load, Stream, Cancel, Dispose + +**Trigger:** `packages/ai-native/src/node/acp/acp-agent.service.ts` or `packages/ai-native/src/browser/chat/acp-session-provider.ts` + +**Layer:** `node-contract` **Required profile:** `default` **Fixtures:** The mock ACP agent at `node test/bdd/fixtures/acp-agent/mock-acp-agent.mjs` covers session creation/loading, `--fixture=stream-rich` streaming, `--fixture=long-stream` cancellation, `--fixture=send-failure` prompt errors, `--fixture=load-failure` load fallback, and advertised HTTP MCP capability controls. **Workspace mutation:** None. **Automation status:** Automated contract spec; browser preflight is optional when validating the visible provider path. + +## Given + +- Common preflight in `test/bdd/README.md` passes if this is run through the IDE. +- The ACP agent command points to the mock ACP agent and can complete `initialize`. +- The agent advertises `sessionCapabilities.list` and `loadSession` when saved session checks are executed. +- The agent advertises `mcpCapabilities.http` when MCP bridge injection checks are executed. + +## When + +### Part A - Create Session + +1. Browser or provider calls `createSession`. +2. Node calls `AcpAgentService.createSession(config)`. +3. The selected `AcpThread` initializes if needed. +4. The thread calls `newSession({ cwd, mcpServers })`. +5. The service records `sessionId -> thread`, registers permission routing, and subscribes to thread status changes. +6. The service waits up to 5 seconds for `available_commands_update`. +7. Repeat create with duplicate command names in `available_commands_update`. +8. Repeat create with no `available_commands_update` before the timeout. + +### Part B - Send Message Stream + +9. Browser sends a prompt through the ACP session. +10. `AcpAgentService.sendMessage({ sessionId, prompt, images, history }, config)` is called. +11. The stream emits the current `thread_status` before prompt updates. +12. The thread transitions `awaiting_prompt -> working -> awaiting_prompt`. +13. User and assistant updates are converted into chat stream updates. +14. Repeat send with a deterministic agent error after the user update. +15. The stream emits `done` or `error` and closes. + +### Part C - Load Or Resume Session + +16. Load an existing `sessionId`. +17. If already active, return the bound thread result without creating a new process. +18. If an idle thread exists, reuse it and call `loadSession`. +19. If load fails in `loadSessionOrNew`, call `newSession` and bind the returned actual session id. + +### Part D - Cancel And Dispose + +20. While a request is working, call `cancelRequest(sessionId)`. +21. Dispose the session with `force=false`. +22. Dispose another active session with `force=true`. +23. Emit a late status update from a disposed thread. +24. Stop or dispose the agent service. + +## Then + +- Part A returns a raw ACP `sessionId` and a deduplicated `availableCommands` array. +- Part A returns an empty `availableCommands` array if no command update arrives before the timeout, without blocking session creation. +- The service never registers a synthetic `acp:` session id on the node session map. +- Permission routing is registered for every live raw ACP session id. +- If the agent supports HTTP MCP and no configured server uses the built-in server name, `newSession` receives one `opensumi-ide` HTTP MCP server. +- Message prompts, images, history, and per-send config are forwarded to the ACP thread with raw session ids. +- Part B emits at least one status update and eventually returns to `awaiting_prompt` after a successful prompt. +- Part B emits a normalized error and returns the thread to a recoverable terminal state after the mock `send-failure` fixture fails. +- Streamed updates for unrelated session ids are ignored. +- `cancelRequest` is idempotent when the session is missing. +- `disposeSession(force=false)` releases session terminals, unregisters permission routing, removes the session mapping, and keeps the thread eligible for reuse. +- `disposeSession(force=true)` also disposes the thread and removes it from the pool. +- Late status updates from disposed or unbound threads do not recreate session mappings or browser status subscriptions. +- If the pool has 3 live non-idle threads, creating or loading another session fails with a thread-pool-full error. +- `stopAgent` or `dispose` releases every thread and leaves no active sessions. + +## Pass / Fail Judgment + +- **PASS** - session lifecycle operations preserve raw session ids, status events, permission routing, MCP bridge injection, and pool cleanup. +- **FAIL** - sessions leak across disposals, status changes are not observable, wrong session updates are streamed, or the MCP bridge is not injected when the agent supports HTTP MCP. diff --git a/test/bdd/acp-chat-agentic-bootstrap-footer.scenario.md b/test/bdd/acp-chat-agentic-bootstrap-footer.scenario.md new file mode 100644 index 0000000000..3f2f6b2d71 --- /dev/null +++ b/test/bdd/acp-chat-agentic-bootstrap-footer.scenario.md @@ -0,0 +1,46 @@ +# Scenario: ACP Chat Agentic Bootstrap Footer - Cold Start Metadata + +**Trigger:** `packages/ai-native/src/browser/acp/components/AcpChatViewWrapper.tsx`, `packages/ai-native/src/browser/chat/chat.internal.service.acp.ts`, `packages/ai-native/src/browser/chat/chat.view.acp.tsx`, or `packages/ai-native/src/browser/acp/components/AcpChatViewHeader.tsx` + +**Layer:** `runtime-ui` **Required profile:** `interactive` **Fixtures:** Agentic startup has passed, the mock ACP agent is configured as `node test/bdd/fixtures/acp-agent/mock-acp-agent.mjs --fixture=stream-rich`, and a fresh MCP session exposes `acp_chat_get_session_state`, `acp_chat_list_sessions`, and `acp_chat_get_available_commands`. The fixture's `session/new` response includes stable `configOptions`, modes, models, and safe command metadata. **Workspace mutation:** None. **Automation status:** Automated through Chrome DevTools MCP plus safe ACP Chat MCP state/list/command tools; Playwright conversion requires deterministic selectors for footer config controls, model/mode affordances, and command entry points. + +## Given + +- The IDE opens directly into Agentic ACP chat from a fresh browser/runtime profile. +- No user prompt has been submitted in the ACP chat input. +- ACP initialization has completed and the mock agent can answer `session/new`. + +## When + +1. Wait until the ACP chat surface is visible and the initializing progress indicator is gone. +2. `mcp`: `acp_chat_get_session_state({})` -> record `STATE_AFTER_STARTUP`. +3. `mcp`: `acp_chat_get_available_commands({})` directly or through the fallback broker -> record `COMMANDS_AFTER_STARTUP`. +4. Record the visible footer config controls, selected config values, model/mode controls, and slash/skill command entry point before typing any prompt. +5. Open the history list and record visible history rows. +6. Submit whitespace-only input and record session state, history rows, and footer controls again. +7. Type a deterministic prompt and send it. +8. Wait until the mock `stream-rich` fixture finishes, then `mcp`: `acp_chat_get_session_state({})` -> record `STATE_AFTER_FIRST_SEND`. +9. Open the history list again and record visible history rows. + +## Then + +- Cold startup creates exactly one reusable ACP bootstrap session before the first user prompt. +- Before the first prompt, the footer shows ACP session-provided config controls, model/mode affordances, and slash/skill command access. +- `STATE_AFTER_STARTUP.result.active === true` and its raw session id has no `acp:` prefix. +- `COMMANDS_AFTER_STARTUP` contains the safe fixture command metadata used by the slash/skill command surface. +- The empty bootstrap session is hidden from visible chat history before user content is sent. +- Whitespace-only input does not create another session, request, message row, or visible history entry, and it does not clear the footer controls. +- The first valid prompt reuses the bootstrap session instead of issuing a second `session/new`. +- After the first valid send, the same session appears as a normal visible history row with user content or a derived title. +- State/list/command tools remain metadata-only and do not expose full prompt bodies, assistant content, tool-call results, config secrets, or permission content. + +## Live Agent Execution + +- A real LLM-backed ACP agent may verify the visible cold-start footer and first-send session reuse when it exposes stable command/config metadata. +- Live-agent mode must not assert assistant text, model-specific command effects, exact generated titles, or generated tool choices. Deterministic fixture coverage is required before Playwright conversion. + +## Pass / Fail Judgment + +- **PASS** - cold startup renders footer metadata through one reusable bootstrap session, keeps the empty session out of visible history, ignores whitespace-only submit, and reuses that session for the first valid prompt. +- **BLOCKED** - the run lacks interactive profile, deterministic `stream-rich` config/command metadata, stable footer/history selectors, or safe MCP state/list/command tools. +- **FAIL** - footer config/model/mode/command controls are missing before first prompt, startup creates multiple ACP sessions, empty bootstrap appears in visible history, whitespace creates a session/request/history row, first valid send creates a second session, or safe tools leak content. diff --git a/test/bdd/acp-chat-agentic-cancel-stop.scenario.md b/test/bdd/acp-chat-agentic-cancel-stop.scenario.md new file mode 100644 index 0000000000..2eb7821c54 --- /dev/null +++ b/test/bdd/acp-chat-agentic-cancel-stop.scenario.md @@ -0,0 +1,49 @@ +# Scenario: ACP Chat Agentic Cancel Stop - Long Stream Interruption + +**Trigger:** `packages/ai-native/src/browser/chat/chat.view.acp.tsx`, `packages/ai-native/src/browser/chat/chat-manager.service.ts`, `packages/ai-native/src/browser/chat/acp-chat-agent.ts`, or `packages/ai-native/src/node/acp/acp-agent.service.ts` + +**Layer:** `runtime-ui` **Required profile:** `interactive` **Fixtures:** Agentic startup has passed, the mock ACP agent is configured as `node test/bdd/fixtures/acp-agent/mock-acp-agent.mjs --fixture=long-stream` with enough ticks/delay to expose the stop control, a `--fixture=stream-rich` pass is available for follow-up success recovery, a fresh MCP session runs in a profile exposing the required `acp_chat` tools, and a visible stop/cancel control exists. A real LLM-backed ACP agent may be used only when it reliably streams long enough for live stop coverage. **Workspace mutation:** None. **Automation status:** Partially converted to deterministic Playwright coverage in `tools/playwright/src/tests/acp-chat-agentic-cancel-stop.test.ts` using `fixture=long-stream` and `profile=interactive`; remaining full follow-up-success/state-tool assertions require a second deterministic success fixture pass. + +## Given + +- Agentic AI Chat is visible and focusable. +- The mock `long-stream` fixture can keep a request in `working` state until the UI interrupts it. +- The scenario must not call legacy direct ACP cancellation tools. + +## When + +1. Send a deterministic long-stream prompt through the Agentic input. +2. Wait until the user row, one assistant row, and visible streaming/loading state are present. +3. Record `acp_chat_get_session_state({})` while the stream is active. +4. Click the user-facing stop/cancel control in the chat UI. +5. Wait until the input becomes editable and no active loading control remains. +6. Record visible assistant row content, stopped/canceled state, duplicate row counts, and thread/history status. +7. Send a second deterministic successful prompt in the same session. +8. Record final row counts, input state, and `acp_chat_get_session_state({})`. + +## Then + +- The first prompt creates exactly one user row and one active assistant response row. +- Stop/cancel is available only while the request is active. +- Clicking stop/cancel does not remove the user row and does not leave the assistant row stuck in a spinner-only state. +- The input becomes editable after cancellation. +- The session remains usable and the second prompt succeeds in the same active session. +- No duplicate assistant rows, duplicate tool cards, or stale loading controls remain after retry. +- State tools remain metadata-only; bounded session titles are allowed, but full canceled prompt/message bodies, partial assistant text, and raw cancellation payloads are not exposed. + +## Live Agent Execution + +- A real LLM-backed ACP agent may verify that a long-enough live stream exposes stop/cancel UI, returns the input to a usable state, preserves the user row, and permits a follow-up send. +- Live-agent mode must not assert partial assistant text, exact cancellation timing, model-specific stop semantics, or generated follow-up content. If the live response completes before stop is observable, record the run as blocked for live stop coverage rather than passing the cancel assertions. + +## Deterministic Playwright Coverage + +- `tools/playwright/src/tests/acp-chat-agentic-cancel-stop.test.ts` runs `loadAcpBddFixtureWorkbench({ fixture: 'long-stream', profile: 'interactive' })`. +- Covered: visible active long-stream sentinel, exactly one user row, visible scoped Stop affordance, Stop returning the scoped Send affordance, and editable input recovery. +- Remaining blocked for this scenario: deterministic follow-up success in the same session, duplicate row/tool-card checks after retry, and metadata-only session-state checks after cancellation. + +## Pass / Fail Judgment + +- **PASS** - long-stream cancellation is visible, leaves the Agentic chat usable, and a follow-up send succeeds without stale loading or duplicate rows. +- **BLOCKED** - the run lacks interactive profile, the mock ACP agent `long-stream` fixture, or stable stop/cancel selector. +- **FAIL** - cancellation is unavailable, uses legacy ACP tools, leaves stuck loading, loses the session, or corrupts subsequent sends. diff --git a/test/bdd/acp-chat-agentic-command-surface.scenario.md b/test/bdd/acp-chat-agentic-command-surface.scenario.md new file mode 100644 index 0000000000..8487d5f3d1 --- /dev/null +++ b/test/bdd/acp-chat-agentic-command-surface.scenario.md @@ -0,0 +1,42 @@ +# Scenario: ACP Chat Agentic Command Surface - Slash and Shortcut Commands + +**Trigger:** `packages/ai-native/src/browser/acp/components/AcpChatInput.tsx`, `packages/ai-native/src/browser/acp/components/AcpChatMentionInput.tsx`, `packages/ai-native/src/browser/chat/chat.view.acp.tsx`, `packages/ai-native/src/browser/chat/acp-chat-agent.ts`, or `packages/ai-native/src/browser/acp/webmcp-groups/acp-chat.webmcp-group.ts` + +**Layer:** `runtime-ui` **Required profile:** `interactive` **Fixtures:** Agentic startup has passed, the mock ACP agent is configured as `node test/bdd/fixtures/acp-agent/mock-acp-agent.mjs --fixture=stream-rich` so available commands are stable, the command picker is visible from input, and a fresh MCP session runs in a profile exposing `acp_chat_get_available_commands`. A real LLM-backed ACP agent may be used only when it exposes stable available commands for the run. **Workspace mutation:** None. **Automation status:** Automated through Chrome DevTools MCP plus `acp_chat_get_available_commands`; live-agent runs may cover command picker/send smoke, but stable mock command metadata and picker selectors are required for conversion. + +## Given + +- Agentic AI Chat is visible and focusable. +- `acp_chat_get_available_commands` is callable directly or through the fallback broker. + +## When + +1. Call `acp_chat_get_available_commands({})` and record safe command metadata. +2. Type `/` in the Agentic input to open the command surface. +3. Record visible command count, labels, descriptions, focus state, and selected item. +4. Press Escape before selecting a command and record whether the picker closes while the literal `/` remains editable user text. +5. Clear the input if needed, reopen the command surface, navigate the command list by keyboard, and select one deterministic command. +6. Record selected command chip/theme, placeholder/default input changes, and send button state. +7. Cancel the selected command and verify command state is cleared. If the input retains literal user-typed text, record it and clear it before the send check. +8. Select the command again, type a deterministic prompt, and send it. +9. Record user row command display, assistant completion, and final input state. + +## Then + +- Visible command names match the safe metadata returned by `acp_chat_get_available_commands`, subject to profile and fixture filtering. +- Pressing Escape while only the picker is open closes the picker and keeps the input editable; it may leave the literal `/` as user text. +- Command selection updates visible input state without sending immediately. +- Canceling a selected command removes command state and restores normal input behavior; it does not have to delete unrelated literal input text. +- Sending with a command produces one user row and one assistant response. +- Command metadata and state tools may expose bounded session title metadata, but do not expose full prompt/message bodies, assistant content, or tool-call results. + +## Live Agent Execution + +- A real LLM-backed ACP agent may verify command discovery, picker navigation, selection/cancel behavior, send shell behavior, and metadata-only command/state responses when its command list is stable for the run. +- Live-agent mode must not assert generated assistant content, exact command-derived session titles, or model-specific command effects. Command catalog parity hardening requires stable command metadata in the active profile. + +## Pass / Fail Judgment + +- **PASS** - slash command discovery, selection, cancellation, send, and metadata parity work in Agentic input. +- **BLOCKED** - the run lacks interactive profile, the mock ACP agent `stream-rich` command metadata, or stable command picker selectors. +- **FAIL** - command UI drifts from metadata, the picker or selected command state gets stuck, sends duplicate rows, or leaks content through command tools. diff --git a/test/bdd/acp-chat-agentic-config-controls.scenario.md b/test/bdd/acp-chat-agentic-config-controls.scenario.md new file mode 100644 index 0000000000..a905629c37 --- /dev/null +++ b/test/bdd/acp-chat-agentic-config-controls.scenario.md @@ -0,0 +1,49 @@ +# Scenario: ACP Chat Agentic Footer Config Options - Session Config Controls + +**Trigger:** `packages/ai-native/src/browser/acp/components/AcpChatInput.tsx`, `packages/ai-native/src/browser/acp/components/AcpChatMentionInput.tsx`, `packages/ai-native/src/browser/chat/chat.view.acp.tsx`, `packages/ai-native/src/browser/chat/acp-chat-agent.ts`, or `packages/ai-native/src/browser/chat/chat.internal.service.acp.ts` + +**Source:** [ACP Session Config Options](https://agentclientprotocol.com/protocol/v1/session-config-options) + +**Layer:** `runtime-ui` **Required profile:** `full` **Fixtures:** Agentic startup has passed, and the deterministic ACP fixture is configured with `node test/bdd/fixtures/acp-agent/mock-acp-agent.mjs --fixture=stream-rich`. The fixture exposes `configOptions` with stable ids for `mode`, `model`, `thought_level`, and a boolean fixture option; each selectable option has at least two values and a current value. Deterministic fixture mode records outbound `session/set_config_option` calls through ACP Debug Log and emits a prompt-turn `BDD_CONFIG_SNAPSHOT` without exposing raw prompt bodies. A fresh MCP session runs in a full profile exposing the required `acp_chat` tools. **Workspace mutation:** None. **Automation status:** Automated through Playwright full-profile runtime plus ACP Debug Log proof records and deterministic fixture stream assertions; live-agent runs may cover visible controls and safe state only, while send-time config snapshots and conversion require deterministic fixture records. + +## Given + +- Agentic AI Chat is visible. +- The active ACP session state includes a `configOptions` list returned by the ACP agent. The list includes: + - An option with `category: "mode"` and a current value rendered as the first footer selector. + - An option with `category: "model"` and a current value rendered as the second footer selector, for example `qwen3.6-plus`. + - An option with `category: "thought_level"` and a current value rendered as another footer selector. +- The footer derives mode/model/thought controls from `configOptions` when that list is present; legacy `agentModes` and `agentModels` selectors must not render duplicate controls in the same footer. +- The fixture supports reversible UI changes for every visible ACP config option. + +## When + +1. Open the Agentic chat input footer and record every visible config selector label, selected value, disabled state, and keyboard focus order. +2. Assert the visible selector count and labels match the normalizable ACP `configOptions` entries in agent-provided order, including options categorized as `mode`, `model`, and `thought_level`. +3. For each required config option, open the footer combobox, select a non-current value, and record the visible value immediately after selection. +4. For each selection, verify the client sent `session/set_config_option` with the active ACP `sessionId`, the exact `configId`, and the selected value. Boolean config options, when present, must send boolean values rather than stringified labels. +5. Verify the agent response supplies a complete `configOptions` list and the footer refreshes from that returned list. If the returned list changes labels, ordering, disabled options, or current values, the footer must reflect the returned list rather than a locally patched single value. +6. Send a deterministic prompt after changing `mode`, `model`, and `thought_level`. Record the fixture prompt-turn config snapshot without asserting assistant text. +7. Record the controls while the prompt is sending, then wait for completion and record final controls, safe session summary, and input state. +8. Restore the original config option values if the fixture requires cleanup. + +## Then + +- Footer controls render only values exposed by the active ACP session `configOptions`. +- The `mode`, `model`, and `thought_level` category changes visibly take effect in the footer and are confirmed by `session/set_config_option` call records using each option's exact `id` as `configId`. +- The sent prompt-turn uses the currently selected config option values. The scenario must not pass if the UI label changes but the outbound ACP config remains unchanged. +- While streaming, footer controls either remain safely usable according to the ACP config option contract or disable only unsafe changes; the selected values must not silently revert. +- Controls become usable again after stream completion or failure. +- Switching config options does not create duplicate sessions, clear existing visible messages, or render duplicate legacy mode/model selectors. +- State tools expose safe metadata only, including optional bounded session title metadata, and do not leak message bodies, assistant text, tool-call output, or config secrets. + +## Live Agent Execution + +- A real LLM-backed ACP agent may verify visible `configOptions` rendering, selection affordances, disabled/loading behavior, returned safe state metadata, and absence of duplicate legacy mode/model controls when it exposes stable option ids and values. +- Live-agent mode must not assert generated assistant text, hidden config secrets, exact prompt-turn effects, or outbound `session/set_config_option` call records unless those records are captured by a deterministic fixture or protocol recorder. Send-time config hardening remains deterministic-fixture only. + +## Pass / Fail Judgment + +- **PASS** - Agentic footer config controls render from ACP `configOptions`, each required option calls `session/set_config_option`, returned `configOptions` refresh the footer, send-time config uses the selected values, and safe session-state reads remain metadata-only. +- **BLOCKED** - the run lacks full profile, deterministic `mode`/`model`/`thought_level` config fixtures, fixture call records, or stable footer control selectors. +- **FAIL** - controls are missing, duplicated, stale, only locally patched when the agent returns a different complete list, ineffective at send time, unsafe during send, unexpectedly duplicate/clear sessions, or state tools leak sensitive values. diff --git a/test/bdd/acp-chat-agentic-context-attachments.scenario.md b/test/bdd/acp-chat-agentic-context-attachments.scenario.md new file mode 100644 index 0000000000..fa68153f74 --- /dev/null +++ b/test/bdd/acp-chat-agentic-context-attachments.scenario.md @@ -0,0 +1,42 @@ +# Scenario: ACP Chat Agentic Context Attachments - Files, Folders, Code, Rules + +**Trigger:** `packages/ai-native/src/browser/acp/components/AcpChatMentionInput.tsx`, `packages/ai-native/src/browser/components/acp/MentionInput.tsx`, `packages/ai-native/src/browser/components/chat-context/**`, or `packages/ai-native/src/browser/chat/chat.view.acp.tsx` + +**Layer:** `runtime-ui` **Required profile:** `interactive` **Fixtures:** Agentic startup has passed, workspace contains `editor.js`, `test/test.js`, and an optional rule fixture, the mock ACP agent is configured as `node test/bdd/fixtures/acp-agent/mock-acp-agent.mjs --fixture=stream-rich` for deterministic attachment send shell coverage, and a fresh MCP session runs in a profile exposing the required `acp_chat` tools. A real LLM-backed ACP agent may be used only for live attachment send smoke coverage. **Workspace mutation:** None. **Automation status:** Automated through Chrome DevTools MCP; live-agent runs may cover picker/chip/send shell behavior, but required workspace fixtures, the mock send fixture, and stable context picker selectors remain mandatory for conversion. + +## Given + +- Agentic AI Chat is visible and the input supports context chips or attachment controls. +- The workspace fixture contains stable files and folders. + +## When + +1. Open the context picker from the Agentic input. +2. Select a file context and record visible chip text and remove control. +3. Select a folder context if the picker exposes folders. +4. Select a code-range context from the active editor if available. +5. Select a rule context if rules are exposed. +6. Remove one selected chip and verify it disappears. +7. Send a deterministic prompt with the remaining selected contexts. +8. Record user row display, assistant response, final input value, and chip cleanup state. +9. Record `acp_chat_get_session_state({})`, and if exposed, `acp_chat_prepare_session_digest({ sourceSessionId })` for metadata-only boundaries. + +## Then + +- Context chips show safe display names, not raw absolute paths when a workspace-relative label is available. +- Removing a chip prevents it from being sent. +- Sent user row renders readable context labels without exposing hidden attachment payload wrappers. +- Input clears after successful send and does not retain stale chips. +- State/digest tools do not expose raw attached file content unless that tool's bounded contract explicitly returns a digest. +- Missing optional context types are recorded as skipped within the scenario, not as failure. + +## Live Agent Execution + +- A real LLM-backed ACP agent may verify context picker visibility, chip add/remove behavior, send shell behavior with selected context, input cleanup, and metadata-only state/digest boundaries when deterministic fixture mode is not required. +- Live-agent mode must not assert generated assistant content, hidden attachment payloads chosen by the model, exact digest text, or full file contents. Attachment cleanup and payload-safety hardening should use deterministic fixtures or bounded protocol records. + +## Pass / Fail Judgment + +- **PASS** - Agentic context selection, removal, send display, and cleanup are stable and metadata-safe. +- **BLOCKED** - the run lacks interactive profile, required workspace files, the mock ACP agent `stream-rich` send fixture, or a stable context picker. +- **FAIL** - stale attachments are sent, chips cannot be removed, raw payloads leak, or send corrupts the input state. diff --git a/test/bdd/acp-chat-agentic-debug-log-from-chat.scenario.md b/test/bdd/acp-chat-agentic-debug-log-from-chat.scenario.md new file mode 100644 index 0000000000..38cc4ad607 --- /dev/null +++ b/test/bdd/acp-chat-agentic-debug-log-from-chat.scenario.md @@ -0,0 +1,42 @@ +# Scenario: ACP Chat Agentic Debug Log From Chat - Trace Viewer Correlation + +**Trigger:** `packages/ai-native/src/node/acp/acp-debug-log.ts`, `packages/ai-native/src/browser/acp/debug-log/acp-debug-log.view.tsx`, `packages/ai-native/src/browser/chat/chat.view.acp.tsx`, or `packages/ai-native/src/node/acp/acp-cli-back.service.ts` + +**Layer:** `runtime-ui` **Required profile:** `full` with ACP debug logging enabled. **Fixtures:** Agentic startup has passed, the mock ACP agent is configured as `node test/bdd/fixtures/acp-agent/mock-acp-agent.mjs --fixture=stream-rich` for a deterministic ACP stream, or a real LLM-backed ACP agent is used only for live raw-log smoke coverage, debug log store/viewer, and command `ai.native.acp.openDebugLog`. **Workspace mutation:** None. **Automation status:** Automated through Chrome DevTools MCP and command execution for the current raw viewer; live-agent raw-log evidence must be redacted and must not include real secrets. Redaction checks are blocked until the product exposes redacted debug-log rendering/copying. + +## Given + +- ACP debug logging is enabled by the active test profile. +- Agentic AI Chat can send a deterministic `stream-rich` mock-agent stream that includes content, tool call, and completion updates. + +## When + +1. Send the deterministic debug-log prompt through Agentic AI Chat. +2. Wait for the stream to complete. +3. Execute `ai.native.acp.openDebugLog`. +4. Wait for the `ACP Debug Log` editor/viewer. +5. Click Refresh. +6. Record entries grouped by thread id, session id, direction, and a locally bounded raw/payload preview for evidence. +7. Click Copy All when entries exist. +8. If redacted debug-log rendering/copying is implemented, search copied text for MCP token paths, API keys, full permission prompts, full relay digests, and raw prompt/assistant sentinel content that should be redacted. +9. Click Clear and verify the viewer empty state. + +## Then + +- The debug log viewer opens as a normal editor/view, not a modal blocking chat. +- Entries correlate to the chat session/thread. +- Refresh, Copy All, and Clear work after a real Agentic chat stream. +- Current copied/debug-rendered text is raw, so deterministic fixtures must not include real secrets. +- When a redacted render/copy contract exists, copied/debug-rendered text redacts MCP tokens, API keys, permission content, relay digest bodies, and raw prompt/assistant sentinel content. +- Clearing the debug log does not clear chat history or active session state. + +## Live Agent Execution + +- A real LLM-backed ACP agent may verify that a live chat stream creates debug log entries, that the viewer opens, and that Refresh, Copy All, and Clear remain usable. +- Live-agent mode must redact evidence and must not assert generated assistant text, raw prompt bodies, model tool arguments/results, API keys, MCP token paths, or permission content. Redaction/copy hardening requires a product redaction contract or synthetic fixtures. + +## Pass / Fail Judgment + +- **PASS** - a real Agentic chat stream creates useful raw debug log entries with usable viewer controls, and test fixtures avoid real secrets. +- **BLOCKED** - the run lacks full profile, debug logging, viewer command, the mock ACP agent `stream-rich` fixture, or redacted render/copy support for Step 8. +- **FAIL** - viewer cannot correlate entries, controls fail, logs grow beyond the store entry limit, or the redaction audit runs and copied text leaks secrets/sensitive content. diff --git a/test/bdd/acp-chat-agentic-deep-thinking-collapse.scenario.md b/test/bdd/acp-chat-agentic-deep-thinking-collapse.scenario.md new file mode 100644 index 0000000000..26b067a31a --- /dev/null +++ b/test/bdd/acp-chat-agentic-deep-thinking-collapse.scenario.md @@ -0,0 +1,51 @@ +# Scenario: ACP Chat Agentic Deep Thinking Collapse + +**Trigger:** `packages/ai-native/src/browser/chat/chat.view.acp.tsx`, `packages/ai-native/src/browser/components/ChatReply.tsx`, or `packages/ai-native/src/browser/chat/chat-model.ts` + +**Layer:** `runtime-ui` **Required profile:** `interactive` **Fixtures:** Agentic startup has passed, the mock ACP agent is configured as `node test/bdd/fixtures/acp-agent/mock-acp-agent.mjs --fixture=stream-rich`, stable selectors or visible text access are available for the Agentic message list and `Deep Thinking` toggle, and a fresh MCP session runs in a profile exposing the required `acp_chat` tools. **Workspace mutation:** None. **Automation status:** Automated through Chrome DevTools MCP plus the deterministic mock ACP agent; live-agent runs may verify only coarse collapsed-shell behavior, while sentinel reasoning assertions require the mock `stream-rich` fixture. + +## Given + +- Agentic AI Chat is visible and focusable. +- Deterministic-fixture mode uses the mock ACP agent `stream-rich` fixture through `AcpThread`. +- The fixture emits stable reasoning sentinel text such as `BDD_THOUGHT_STEP_1` and `BDD_CONFIG_SNAPSHOT`. +- The scenario validates visible message-list behavior, not raw ACP notification JSON. + +## When + +1. Focus the Agentic input and send the deterministic deep-thinking prompt through the UI. +2. Wait until the assistant row shows the `Deep Thinking` toggle while the response is still streaming. +3. Record a visible text snapshot of the assistant row before interacting with the toggle. +4. Wait for the deterministic stream to complete without expanding `Deep Thinking`. +5. Record another visible text snapshot of the final assistant row. +6. Click the `Deep Thinking` toggle on the completed assistant row. +7. Record the expanded reasoning content, then click the same toggle again. +8. Start a second deterministic stream-rendering prompt in a fresh ACP session. +9. While the response is still streaming and after the first reasoning chunk, click the `Deep Thinking` toggle. +10. Wait for the next reasoning chunk and record the expanded reasoning content. +11. Let the stream complete and record the final assistant row state. +12. `mcp`: `acp_chat_get_session_state({})` -> record `STATE_AFTER_DEEP_THINKING`. + +## Then + +- The `Deep Thinking` toggle is visible for reasoning updates in the assistant response. +- Before any toggle click, reasoning sentinel text is not visible in the message list while streaming. +- If no toggle click occurs, reasoning sentinel text remains hidden after the assistant response completes. +- Clicking `Deep Thinking` on a completed response expands the reasoning content and reveals the deterministic sentinel text. +- Clicking the same toggle again collapses the content and hides the sentinel text. +- Clicking `Deep Thinking` during streaming expands the reasoning content instead of being ignored. +- After streaming reasoning is expanded, later reasoning chunks appear inside the same expanded response without creating duplicate assistant rows or duplicate `Deep Thinking` toggles. +- The final assistant row preserves the user's last explicit expanded/collapsed choice for that response. +- `STATE_AFTER_DEEP_THINKING` returns `success: true` and remains metadata-only; it must not include full prompt/message bodies, assistant text, reasoning text, raw ACP JSON, MCP tokens, or permission content. +- No step uses or expects legacy direct ACP tools such as `acp_sendMessage`, `acp_cancelRequest`, or older camelCase ACP Chat tool names. + +## Live Agent Execution + +- A real LLM-backed ACP agent may verify that `Deep Thinking` appears as a collapsed shell during streaming and after completion. +- Live-agent mode must not assert exact reasoning text, chunk order, token timing, or generated assistant content. + +## Pass / Fail Judgment + +- **PASS** - ACP Agentic `Deep Thinking` content is collapsed by default during streaming and after completion, remains user-expandable, preserves explicit toggle state, and does not duplicate rows or leak content through state tools. +- **BLOCKED** - the run lacks interactive profile, the mock ACP agent `stream-rich` fixture, stable `Deep Thinking` toggle selectors, or a supported browser/MCP execution surface. +- **FAIL** - reasoning content is visible by default, the streaming toggle cannot be expanded, explicit toggle state is lost, duplicate assistant rows/toggles appear, or ACP Chat state tools leak message/reasoning/raw protocol content. diff --git a/test/bdd/acp-chat-agentic-draft-footer.scenario.md b/test/bdd/acp-chat-agentic-draft-footer.scenario.md new file mode 100644 index 0000000000..923c6c5843 --- /dev/null +++ b/test/bdd/acp-chat-agentic-draft-footer.scenario.md @@ -0,0 +1,49 @@ +# Scenario: ACP Chat Agentic Draft Footer - Lazy Session Controls + +**Trigger:** `packages/ai-native/src/browser/acp/components/AcpChatViewWrapper.tsx`, `packages/ai-native/src/browser/chat/chat.view.acp.tsx`, `packages/ai-native/src/browser/chat/chat.internal.service.acp.ts`, `packages/ai-native/src/browser/acp/components/AcpChatMentionInput.tsx`, or `packages/ai-native/src/browser/components/acp/MentionInput.tsx` + +**Layer:** `runtime-ui` **Required profile:** `interactive` **Fixtures:** Agentic startup has passed, the mock ACP agent is configured as `node test/bdd/fixtures/acp-agent/mock-acp-agent.mjs --fixture=stream-rich`, one deterministic send has completed so the active session exposes stable ACP `configOptions` and `availableCommands`, and a fresh MCP session runs in a profile exposing `acp_chat_get_session_state`, `acp_chat_list_sessions`, and `acp_chat_get_available_commands`. A real LLM-backed ACP agent may be used only when it exposes stable footer config options and command metadata for the run. **Workspace mutation:** None. **Automation status:** Automated through Chrome DevTools MCP plus safe ACP Chat MCP state/command tools; Playwright conversion requires deterministic fixture selectors for the footer config controls and command surface. + +## Given + +- Agentic AI Chat is visible and the input footer has already rendered ACP session-provided `configOptions`. +- The slash/skill command footer entry point is visible or the `/` command surface can be opened from the input. +- `acp_chat_get_available_commands` returns safe command metadata for the active fixture session. +- The check starts from an active session created by a deterministic send, then uses the visible New Chat action to enter a fresh draft. + +## When + +1. `mcp`: `acp_chat_get_session_state({})` -> record `STATE_ACTIVE`. +2. `mcp`: `acp_chat_get_available_commands({})` directly or through the fallback broker -> record `COMMANDS_ACTIVE`. +3. Record the active footer config controls: count, order, selected values, disabled state, and whether legacy duplicate mode/model controls are absent when `configOptions` are present. +4. Record the slash/skill command entry point and open the command surface once to capture visible command names, focus state, and dismiss behavior. +5. Click the Agentic chat header New Chat action. +6. `mcp`: `acp_chat_get_session_state({})` -> record `STATE_DRAFT`. +7. `mcp`: `acp_chat_list_sessions({})` directly or through the fallback broker -> record `SESSIONS_AFTER_NEW_CHAT`. +8. Before typing any valid prompt, record the draft footer config controls and slash/skill command entry point again. +9. Open the draft command surface with `/`, compare visible command names with `COMMANDS_ACTIVE`, then dismiss without sending. +10. Submit whitespace-only input and record state, history rows, session list count, and footer visibility. +11. Type a deterministic prompt in the draft and send it. +12. Wait for the mock `stream-rich` fixture to finish, then `mcp`: `acp_chat_get_session_state({})` -> record `STATE_AFTER_FIRST_SEND`. + +## Then + +- New Chat enters a draft/inactive state and does not eagerly create or persist a new ACP session. +- `STATE_DRAFT` is inactive or has no active session id before the valid send, and `SESSIONS_AFTER_NEW_CHAT` has no additional empty draft row. +- The draft footer still shows the same normalized ACP config option controls that were visible on the active session, including selected values and ordering. +- The draft footer still exposes the slash/skill command entry point, and the command surface remains aligned with safe `COMMANDS_ACTIVE` metadata. +- Whitespace-only submit does not create a session, request, message row, or empty history entry, and it does not clear the draft footer controls. +- The first valid draft send creates or activates the next ACP session before writing history, and `STATE_AFTER_FIRST_SEND.result.active === true` with a non-empty raw session id that has no `acp:` prefix. +- After the first valid send, footer config controls refresh from the created session state without duplicating legacy mode/model selectors or losing command access. +- State/list/command tools remain metadata-only and do not expose full prompt bodies, assistant content, tool-call results, config secrets, or permission content. + +## Live Agent Execution + +- A real LLM-backed ACP agent may verify the visible draft footer, command entry point, lazy session creation, and metadata-only state when it exposes stable command/config metadata. +- Live-agent mode must not assert assistant text, model-specific command effects, exact session titles, or generated tool choices. Deterministic fixture coverage is required before Playwright conversion. + +## Pass / Fail Judgment + +- **PASS** - New Chat keeps the Agentic draft footer usable without creating a session, whitespace-only input stays inert, and the first valid send creates the ACP session while preserving footer config and command access. +- **BLOCKED** - the run lacks interactive profile, deterministic `stream-rich` config/command metadata, a stable New Chat action, or stable footer/command selectors. +- **FAIL** - draft footer config options or slash/skill commands disappear before first send, New Chat eagerly creates an empty session, whitespace creates a session, first valid send fails from draft, duplicate controls render, or safe tools leak content. diff --git a/test/bdd/acp-chat-agentic-error-taxonomy.scenario.md b/test/bdd/acp-chat-agentic-error-taxonomy.scenario.md new file mode 100644 index 0000000000..db31cc823c --- /dev/null +++ b/test/bdd/acp-chat-agentic-error-taxonomy.scenario.md @@ -0,0 +1,43 @@ +# Scenario: ACP Chat Agentic Error Taxonomy - Visible Recovery by Failure Class + +**Trigger:** `packages/ai-native/src/browser/chat/chat.view.acp.tsx`, `packages/ai-native/src/browser/chat/chat.internal.service.acp.ts`, `packages/ai-native/src/browser/chat/acp-session-provider.ts`, `packages/ai-native/src/browser/chat/acp-chat-agent.ts`, or `packages/ai-native/src/node/acp/acp-agent.service.ts` + +**Layer:** `runtime-ui` **Required profile:** `interactive` **Fixtures:** Agentic startup has passed, the mock ACP agent runs separate passes for `--fixture=create-failure`, `--fixture=load-failure`, `--fixture=send-failure`, `--fixture=auth-required`, `--fixture=config-failure`, `--fixture=process-exit`, and `--fixture=stream-rich` retry recovery, and a fresh MCP session runs in a profile exposing the required `acp_chat` tools. A real LLM-backed ACP agent may be used only for incidental live recovery observations when the environment naturally enters one of these states. **Workspace mutation:** None. **Automation status:** Converted to `tools/playwright/src/tests/acp-chat-agentic-error-taxonomy.test.ts` for deterministic create, load, send, auth-required, config, and process-exit failure passes plus `stream-rich` recovery after each failure. + +## Given + +- Agentic AI Chat is visible. +- Each failure mode is deterministic and can be reset before the next case by restarting the mock ACP agent with the next fixture name. +- Failure messages use stable sentinel text owned by the fixture. + +## When + +1. Run `--fixture=create-failure` and record visible error, input state, and session list state. +2. Reset and run `--fixture=load-failure` from history selection. +3. Reset and run `--fixture=send-failure` after a user row has rendered. +4. Reset and run `--fixture=auth-required`. +5. Reset and run `--fixture=process-exit` for the disconnected agent subcase. +6. Reset and run `--fixture=config-failure`. +7. After each failure, run `--fixture=stream-rich` and send a deterministic successful prompt. +8. Record `acp_chat_get_session_state({})` and browser console errors without secrets. + +## Then + +- Each failure class shows a user-visible, bounded, non-stack-trace error. +- Input and loading state recover after each failure. +- Create/load failures do not persist empty duplicate sessions. +- Send failures preserve the user row and allow retry. +- Auth-required/disconnected states are visible without making hidden mutation tools available. +- Successful retry clears stale failure UI. +- State tools and console diagnostics do not leak prompts, assistant content, API keys, MCP tokens, raw ACP JSON, or permission bodies. + +## Live Agent Execution + +- A real LLM-backed ACP agent may provide evidence for naturally occurring auth, disconnected, send, or config recovery states when those states are reproducible in the live environment. +- Live-agent mode must not substitute for forced create/load/send/auth/disconnect/config failure coverage. It must not assert generated assistant content or exact model error wording; the mock-agent failure taxonomy fixture passes remain required for a full PASS and conversion. + +## Pass / Fail Judgment + +- **PASS** - all scheduled deterministic ACP failure classes surface safe visible recovery and remain retryable. +- **BLOCKED** - the run lacks interactive profile or a required mock ACP agent failure fixture pass. +- **FAIL** - errors are silent, unbounded, leaking, unrecoverable, or leave stale session/loading state. diff --git a/test/bdd/acp-chat-agentic-fallback.scenario.md b/test/bdd/acp-chat-agentic-fallback.scenario.md new file mode 100644 index 0000000000..ab48c3a224 --- /dev/null +++ b/test/bdd/acp-chat-agentic-fallback.scenario.md @@ -0,0 +1,38 @@ +# Scenario: ACP Chat Agentic Fallback - Usable Surface Without ACP Backend + +**Trigger:** `packages/ai-native/src/browser/acp/components/AcpChatViewWrapper.tsx`, `packages/ai-native/src/browser/chat/chat.view.acp.tsx`, or `packages/ai-native/src/browser/chat/chat.internal.service.acp.ts` + +**Layer:** `runtime-ui` **Required profile:** `default` **Fixtures:** IDE dev server with ACP backend readiness forced to fail by the local-loopback `acpBddBackendReadyFailure=reject` runtime fixture before chat initialization. **Workspace mutation:** None. **Automation status:** Automated through Playwright regression; Chrome DevTools MCP may be used for manual evidence. + +## Given + +- Common Preflight passes. +- The IDE is started in Agentic layout. +- ACP backend readiness fails deterministically before ACP chat initialization. + +## When + +1. Open the workspace in Agentic layout. +2. Show the AI Chat view through MCP or Chrome DevTools MCP. +3. Wait for the chat view to render without waiting for a real ACP session. +4. Record visible chat UI, fatal UI text, loading/retry text, and input focusability. +5. If the MCP bridge is available, call the default ACP Chat state tools. + +## Then + +- Agentic AI Chat still renders a usable chat surface through the local fallback path. +- The fallback path does not create an infinite loading state and does not require a real ACP session to render children. +- Hidden ACP mutation tools remain unavailable. +- ACP Chat state tools either return a structured service-unavailable result or safe metadata for the fallback session. +- No state or visible UI exposes uncaught stack traces, raw JSON-RPC payloads, MCP tokens, full prompt/message bodies, assistant text, or permission content outside allowed title metadata. + +## Live Agent Execution + +- A real LLM-backed ACP agent is not a substitute for this scenario, because the contract is the UI fallback when ACP backend readiness fails before chat initialization. +- Live-agent runs may verify the normal healthy path separately, but this scenario remains blocked until a backend-failure fixture or test provider can force readiness failure. + +## Pass / Fail Judgment + +- **PASS** - ACP backend failure still leaves a usable Agentic chat surface and structured safe state responses. +- **BLOCKED** - no deterministic backend-failure fixture is available. +- **FAIL** - the page enters infinite loading, fallback throws unstructured errors, hidden mutation tools appear, or sensitive content leaks. diff --git a/test/bdd/acp-chat-agentic-history.scenario.md b/test/bdd/acp-chat-agentic-history.scenario.md new file mode 100644 index 0000000000..1d7c3e9a3f --- /dev/null +++ b/test/bdd/acp-chat-agentic-history.scenario.md @@ -0,0 +1,47 @@ +# Scenario: ACP Chat Agentic History - New Chat and Session Switching + +**Trigger:** `packages/ai-native/src/browser/acp/components/AcpChatViewHeader.tsx`, `packages/ai-native/src/browser/chat/chat.view.acp.tsx`, `packages/ai-native/src/browser/chat/chat.internal.service.acp.ts`, or `packages/ai-native/src/browser/acp/webmcp-groups/acp-chat.webmcp-group.ts` + +**Layer:** `runtime-ui` **Required profile:** `interactive` **Fixtures:** Agentic startup and input/send scenarios have passed, the mock ACP agent is configured as `node test/bdd/fixtures/acp-agent/mock-acp-agent.mjs --fixture=history` for seeded multi-session assertions, `--fixture=stream-rich` may be used for a normal send pass, and at least two ACP sessions are visible when selection checks run. A real LLM-backed ACP agent may be used only for live session-list/switch smoke coverage. **Workspace mutation:** None. **Automation status:** Converted to `tools/playwright/src/tests/acp-chat-agentic-history.test.ts` with `fixture=history`, `profile=interactive`, deterministic seeded sessions, and metadata-only `acp_chat_list_sessions` / `acp_chat_get_session_state` assertions. + +## Given + +- Agentic AI Chat is visible and the active profile exposes the required `acp_chat` tools in a fresh MCP session. +- History checks run after at least one successful deterministic send, or against the mock `history` fixture's seeded sessions when the check does not need message content. +- Full session-switching assertions require at least two deterministic persisted sessions from the mock `history` fixture. If a live run only has one session, record New Chat/history metadata observations and mark the session-switching portion **BLOCKED**, not **FAIL**. +- Pending permission badge checks run only when the fixture can create pending permission state without exposing permission content. + +## When + +1. Click the Agentic chat header New Chat action. +2. `mcp`: `acp_chat_get_session_state({})` -> record `STATE_AFTER_NEW_CHAT`. +3. `mcp`: `acp_chat_list_sessions({})` directly or through the fallback broker -> record `SESSIONS_AFTER_NEW_CHAT`. +4. Send one short prompt through the UI in the new draft and wait for the mock `stream-rich` fixture to finish. +5. Open the Agentic chat history surface from the header. +6. Record history visibility, item count, item ids/titles/timestamps/current markers, New Chat action count, collapse/expand state, and pending permission badge counts. +7. `mcp`: `acp_chat_list_sessions({})` -> record `SESSIONS_WITH_HISTORY_OPEN`. +8. If at least two sessions are visible, select the older item, record state/header/message view, then select the newer item and record state/header/message view. +9. Collapse and reopen history. +10. If any non-active session has pending permission, record whether header/history badges show scoped counts without permission content. + +## Then + +- Clicking New Chat enters draft state and does not eagerly persist another empty ACP session before the next send. +- Empty draft sessions do not create duplicate `(untitled)` or `New Session` rows. +- History order matches the session list order expected by ACP, newest first by `createdAt` or first-message timestamp. +- Each visible history item has a stable session id and a non-empty safe title. +- Selected/current markers follow `acp_chat_get_session_state` after selection and reselection. +- History collapse/reopen preserves active session selection and does not duplicate header actions. +- History item titles are allowed metadata. `acp_chat_list_sessions` remains metadata-only and must not include full message bodies, assistant content, tool-call results, or permission content. +- Pending permission badges show counts/scoped state only and do not expose approval/rejection controls or permission content. + +## Live Agent Execution + +- A real LLM-backed ACP agent may create or load live sessions to verify New Chat, list visibility, selection switching, active-session highlighting, and metadata-only session state. +- Live-agent mode must not assert exact generated session titles, history ordering derived from model timing, full message restoration, or assistant content. Stable multi-session history hardening remains deterministic-fixture only. + +## Pass / Fail Judgment + +- **PASS** - New Chat draft behavior, persisted history, session selection, and badge observability stay consistent and metadata-only with at least two deterministic sessions. +- **BLOCKED** - the run lacks interactive profile, the mock ACP agent `history` fixture, at least two ACP sessions for selection checks, or a stable history selector. +- **FAIL** - empty drafts persist as history rows, selection state drifts, history leaks message/tool/permission content outside allowed title metadata, or permission badges expose decision controls/content. diff --git a/test/bdd/acp-chat-agentic-input-send.scenario.md b/test/bdd/acp-chat-agentic-input-send.scenario.md new file mode 100644 index 0000000000..2113f3d5f5 --- /dev/null +++ b/test/bdd/acp-chat-agentic-input-send.scenario.md @@ -0,0 +1,53 @@ +# Scenario: ACP Chat Agentic Input and Send - Draft Lifecycle and Recovery + +**Trigger:** `packages/ai-native/src/browser/acp/components/AcpChatInput.tsx`, `packages/ai-native/src/browser/acp/components/AcpChatMentionInput.tsx`, `packages/ai-native/src/browser/chat/chat.view.acp.tsx`, `packages/ai-native/src/browser/chat/chat.internal.service.acp.ts`, or `packages/ai-native/src/browser/acp/webmcp-groups/acp-chat.webmcp-group.ts` + +**Layer:** `runtime-ui` **Required profile:** `interactive` **Fixtures:** `acp-chat-agentic-startup.scenario.md` has passed, the mock ACP agent is configured as `node test/bdd/fixtures/acp-agent/mock-acp-agent.mjs --fixture=stream-rich` for successful send assertions, separate `--fixture=create-failure` and `--fixture=send-failure` passes cover recovery assertions, and a fresh MCP session runs in a profile exposing the required `acp_chat` tools. A real LLM-backed ACP agent may be used only for live shell/send coverage. **Workspace mutation:** None. **Automation status:** Recovery subcases are converted in `tools/playwright/src/tests/acp-chat-agentic-error-taxonomy.test.ts`: `send-failure` preserves the user row and exposes retry, `create-failure` leaves the draft input recoverable, and each is followed by a separate `stream-rich` recovery pass. Broader input, command, mention, attachment, and scroll checks remain governed by this scenario. + +## Given + +- The Agentic chat surface is visible and focusable. +- Parts that send a message run against the process-level mock ACP agent through the real `AcpThread` stdio/JSON-RPC path. +- First-send assertions start from a fresh draft. If the page opens on an existing or stale active session, click New Chat before Step 1; record any stale-session send failure as reload/session-recovery evidence instead of the primary input-send verdict. +- The scenario may assert bounded session title metadata, but must not assert full prompt/message bodies, assistant response text, or tool-call result content through ACP Chat state tools. + +## When + +1. Ensure the Agentic input is in a fresh draft/New Chat state, then `mcp`: `acp_chat_get_session_state({})` -> record `STATE_BEFORE_SEND`. +2. Record the visible empty/welcome state, header title, close action, input editor state, placeholder, send action state, shortcut command buttons, and model/mode controls if rendered. +3. Focus the input, type whitespace only, and attempt to submit. +4. Record whether any user message row was added and whether the send action stayed disabled. +5. Type a multi-line prompt using `Shift+Enter`, then submit with the normal send shortcut or send button. +6. Wait until the input returns to an idle editable state or the mock `stream-rich` fixture emits a terminal assistant update. +7. `mcp`: `acp_chat_get_session_state({})` -> record `STATE_AFTER_SEND`. +8. `mcp`: `acp_chat_get_permission_state({})` -> record `PERMISSION_AFTER_SEND`. +9. Record user message count, assistant message count, duplicate ids/rows, loading controls, final input value, and whether the latest message is visible. +10. Open the slash command surface by typing `/`, select one visible command, and record command list focus plus selected command chip/theme. +11. If `acp_chat_get_available_commands` is exposed, compare visible command names with tool results. +12. Open the mention/context picker by typing `@`, select `editor.js` or the current editor when available, and remove the chip. +13. If attachment controls are enabled, attach a small test file, verify preview/remove state, remove it, and verify no stale attachment is sent. +14. With the message list taller than the viewport, verify bottom auto-scroll for new output and verify an upward user scroll is not overwritten until a new send or explicit bottom-scroll action. +15. Run the mock ACP agent once with `--fixture=create-failure` and once with `--fixture=send-failure`, record visible recovery state for each pass, then retry with `--fixture=stream-rich`. + +## Then + +- Whitespace-only submits do not create a session, message, or request. +- `STATE_BEFORE_SEND` is draft/inactive before first-send checks, and the first valid send creates or activates an ACP session before writing history. +- `STATE_AFTER_SEND.result.active === true`, with non-empty `sessionId` and a raw id that has no `acp:` prefix. +- The input preserves line breaks before send, clears after successful send, and is disabled only while session creation or sending is active. +- The user message appears exactly once and before the assistant response. +- Assistant loading/streaming renders a single active row and resolves to a stable final row without duplicate ids or duplicate DOM rows. +- Send/cancel/stop controls reflect loading state and do not expose old direct ACP tools. +- Commands, mentions, and attachments update visible chips/control state without leaking raw payloads through state, list, or permission tools outside allowed title metadata. +- User-visible errors re-enable input, clear stale loading/error state after retry, and do not persist half-created empty sessions. + +## Live Agent Execution + +- A real LLM-backed ACP agent may verify input focus, draft/send lifecycle, user row creation, loading or streaming transition, input recovery, and metadata-only state. +- Live-agent mode must not assert generated assistant text, exact response timing, model-selected tool choices, command-derived titles, or retry content. Failure injection and retry hardening still require a deterministic fixture before Playwright conversion. + +## Pass / Fail Judgment + +- **PASS** - draft input, first send, commands, mentions, attachments, scroll, and recovery behave as a complete Agentic chat surface. +- **BLOCKED** - the run lacks interactive profile, the mock ACP agent `stream-rich`/failure fixture passes, or a stable New Chat/fresh draft entry point. +- **FAIL** - valid sends from the fresh draft fail, duplicate messages appear, raw message/tool content leaks through state tools outside allowed title metadata, or recovery leaves stale loading/session state. diff --git a/test/bdd/acp-chat-agentic-keyboard-a11y.scenario.md b/test/bdd/acp-chat-agentic-keyboard-a11y.scenario.md new file mode 100644 index 0000000000..070e2768b6 --- /dev/null +++ b/test/bdd/acp-chat-agentic-keyboard-a11y.scenario.md @@ -0,0 +1,40 @@ +# Scenario: ACP Chat Agentic Keyboard Accessibility - No Mouse Critical Path + +**Trigger:** `packages/ai-native/src/browser/acp/components/AcpChatInput.tsx`, `packages/ai-native/src/browser/acp/components/AcpChatHistory.tsx`, `packages/ai-native/src/browser/chat/chat.view.acp.tsx`, or `packages/ai-native/src/browser/acp/permission-dialog-container.tsx` + +**Layer:** `runtime-ui` **Required profile:** `interactive` **Fixtures:** Agentic startup has passed, the mock ACP agent uses `--fixture=stream-rich` for keyboard send/tool-card assertions, `--fixture=history` for at least two sessions during history checks, `--fixture=permission` for dialog keyboard dismissal when the full-profile permission subcase runs, and stable keyboard-focus selectors are available. A real LLM-backed ACP agent may be used only for live keyboard send smoke coverage. **Workspace mutation:** None. **Automation status:** Automated through Chrome DevTools MCP keyboard events; live-agent runs may cover keyboard focus/send and dismissal behavior, but mock-agent fixtures remain required for stable history, permission, and tool-card keyboard assertions. + +## Given + +- Agentic AI Chat is visible. +- The user can reach chat input from the workbench using keyboard navigation. + +## When + +1. Use keyboard navigation to focus the Agentic input. +2. Type a multi-line deterministic prompt with keyboard only. +3. Submit with the supported keyboard shortcut. +4. Open the slash command list with `/`, navigate it with arrow keys, select, then cancel selection with Escape. +5. Open history by keyboard, move between items, select a different session, and return to input. +6. If a permission fixture is available in this profile, open a pending dialog and dismiss it by keyboard. +7. Expand and collapse a tool-call card by keyboard if the card is present. + +## Then + +- Focus order reaches input, command surface, history, tool cards, and dialogs without trapping focus. +- Keyboard submit creates one user row and one assistant response. +- Escape closes transient command/history/dialog surfaces without clearing unrelated input unexpectedly. +- Selected history item and active session state remain aligned. +- Tool-card keyboard expansion exposes arguments/result when present. +- No keyboard-only path requires legacy ACP tools or hidden controls. + +## Live Agent Execution + +- A real LLM-backed ACP agent may verify keyboard-only focus, multi-line submit, one user row creation, loading/recovery, Escape dismissal, and metadata-only state. +- Live-agent mode must not assert generated assistant content, exact history ordering, permission dialog availability, or tool-card arguments/results unless those surfaces are provided by deterministic fixtures. + +## Pass / Fail Judgment + +- **PASS** - core Agentic chat workflows are keyboard-accessible and preserve focus/session state. +- **BLOCKED** - the run lacks interactive profile, stable focus selectors, or the required mock ACP agent fixture pass for the subcase. +- **FAIL** - focus traps, keyboard submit fails, surfaces cannot be dismissed, or selection state drifts. diff --git a/test/bdd/acp-chat-agentic-layout-interop.scenario.md b/test/bdd/acp-chat-agentic-layout-interop.scenario.md new file mode 100644 index 0000000000..1d441289a9 --- /dev/null +++ b/test/bdd/acp-chat-agentic-layout-interop.scenario.md @@ -0,0 +1,43 @@ +# Scenario: ACP Chat Agentic Layout Interop - Explorer, Resize, Reload, Switch + +**Trigger:** `packages/ai-native/src/browser/layout/ai-layout.tsx`, `packages/ai-native/src/browser/layout/panel-layout.service.ts`, `packages/ai-native/src/browser/layout/tabbar.view.tsx`, `packages/ai-native/src/browser/acp/components/AcpChatViewWrapper.tsx`, or `packages/ai-native/src/browser/acp/webmcp-groups/acp-chat.webmcp-group.ts` + +**Layer:** `runtime-ui` **Required profile:** `interactive` **Fixtures:** Agentic startup has passed, workspace contains `editor.js` and `test/test.js`, read-only workspace/editor tools are exposed, and optionally a real LLM-backed ACP agent has populated chat content for live layout smoke coverage. **Workspace mutation:** None. **Automation status:** Automated through Chrome DevTools MCP; read-only MCP checks run through `opensumi-ide`. Live-agent content is optional and must not gate the read-only layout interop contract. + +## Given + +- Agentic AI Chat is visible as the leftmost major column. +- The Explorer activity item can reveal the file tree. +- Read-only workspace/editor/file tools are available through the active profile. + +## When + +1. Open Explorer in Agentic layout. +2. Expand `test`, open `test/test.js`, then open `editor.js`. +3. `mcp`: call read-only tools exposed by the active profile, including `workspace_get_info({})`, `editor_get_active({})`, and `workspace_list_open_files({})`. +4. If file tools are exposed, call only read-only file tools against existing default-workspace files, such as `file_exists({ path: "editor.js" })` and `file_read({ path: "editor.js" })`. +5. Drag the Agentic AI Chat/workbench splitter smaller and larger, then record AI Chat and workbench geometry after each drag. +6. Drag the Agentic Explorer/workbench splitter smaller and larger, then record Explorer and workbench geometry after each drag. +7. Reload the page without changing the workspace URL and repeat startup visibility, state, input, history, and read-only MCP checks. +8. Switch `Agentic -> Classic -> Agentic` through the user-facing layout selector and repeat visibility, geometry, Explorer/editor, input, history, and read-only MCP checks. + +## Then + +- Explorer remains interactive while AI Chat is leftmost. +- Opening files updates `editor_get_active` and `workspace_list_open_files`. +- Read-only workspace/editor/file WebMCP calls continue to work before resize, after resize, after reload, and after layout switching. +- Agentic AI Chat/workbench resizing respects `640px <= AI Chat width <= 1440px` and `workbench.width >= 480px`. +- Agentic Explorer/workbench resizing keeps Explorer recoverable and does not collapse the file tree to a permanent `0px` width. +- Reload preserves Agentic mode and restores a usable AI Chat plus workbench layout. +- Switching Agentic to Classic and back restores Agentic leftmost chat layout without losing Explorer/editor interop. + +## Live Agent Execution + +- A real LLM-backed ACP agent may provide populated chat content while verifying Explorer/editor interop, resize, reload, and Agentic/Classic round trips. +- Live-agent mode must not assert generated assistant text, model timing, or exact restored message content. Core read-only workspace/editor and layout assertions remain deterministic and model-output independent. + +## Pass / Fail Judgment + +- **PASS** - Explorer/editor interop, workspace-scoped read-only MCP calls, resize, reload, and layout switching remain stable in Agentic layout. +- **BLOCKED** - the run lacks interactive profile, the required workspace files, or read-only workspace/editor tool exposure. +- **FAIL** - Explorer/editor interaction breaks, resize bounds fail, reload loses Agentic layout, or layout switching leaves AI Chat/Explorer unusable. diff --git a/test/bdd/acp-chat-agentic-layout-stress.scenario.md b/test/bdd/acp-chat-agentic-layout-stress.scenario.md new file mode 100644 index 0000000000..d94acbadec --- /dev/null +++ b/test/bdd/acp-chat-agentic-layout-stress.scenario.md @@ -0,0 +1,48 @@ +# Scenario: ACP Chat Agentic Layout Stress - Long Content and Dense UI + +**Trigger:** `packages/ai-native/src/browser/layout/ai-layout.tsx`, `packages/ai-native/src/browser/layout/panel-layout.service.ts`, `packages/ai-native/src/browser/chat/chat.view.acp.tsx`, `packages/ai-native/src/browser/components/acp/ChatReply.tsx`, or `packages/ai-native/src/browser/components/ChatToolRender.tsx` + +**Layer:** `runtime-ui` **Required profile:** `interactive` **Fixtures:** Agentic startup has passed, the mock ACP agent uses `--fixture=long-stream` for long active content and `--fixture=stream-rich` for reasoning/plan/tool-card layout assertions, optionally a real LLM-backed ACP agent covers live populated-chat layout, and the workspace has Explorer visible. A single long-rich fixture is still required if the run must assert long text, long reasoning, long plan, and long tool result in one pass. **Workspace mutation:** None. **Automation status:** Partially converted to deterministic Playwright coverage in `tools/playwright/src/tests/acp-chat-agentic-layout-stress.test.ts` using `fixture=long-stream` and `profile=interactive`; long-rich reasoning/plan/tool-result and full layout round-trip assertions remain blocked until a combined fixture or separate stable passes cover them. + +## Given + +- Agentic AI Chat and Explorer/workbench are visible. +- The mock `long-stream` fixture can emit long bounded content without relying on an LLM; the mock `stream-rich` fixture covers reasoning, plan, and tool-card shape. +- Live-agent mode may provide populated chat evidence only when the generated model output is treated as variable and redacted evidence. + +## When + +1. Send a deterministic long-content prompt. +2. Wait for long text, reasoning, plan, and tool result to render. +3. Record AI Chat, workbench, Explorer, input, history, and status bar geometry. +4. Resize the Agentic AI Chat/workbench splitter smaller and larger within allowed bounds. +5. Resize the browser viewport to a narrow desktop size and then a wide desktop size. +6. Expand and collapse the long tool result and reasoning sections. +7. Scroll up and down in the message list. +8. Switch Agentic to Classic and back to Agentic. + +## Then + +- Long content wraps or scrolls inside the chat surface without overlapping the input, history, Explorer, or status bar. +- AI Chat width remains within Agentic bounds and workbench remains usable. +- Tool result expansion does not resize the page into an unusable layout. +- Message list scroll remains usable and bottom-scroll behavior does not jump unexpectedly after manual upward scroll. +- Layout switching preserves visible chat content or restores it safely without duplicate rows. +- No fatal UI text or uncaught stack appears. + +## Live Agent Execution + +- A real LLM-backed ACP agent may verify that populated live responses do not break scrolling, resizing, expansion/collapse, or Agentic/Classic layout round trips. +- Live-agent mode must not assert exact long text, reasoning, plan, tool-card content, or scroll positions derived from generated output. Dense-content and tool-result layout hardening remains deterministic-fixture only. + +## Deterministic Playwright Coverage + +- `tools/playwright/src/tests/acp-chat-agentic-layout-stress.test.ts` runs `loadAcpBddFixtureWorkbench({ fixture: 'long-stream', profile: 'interactive' })`. +- Covered: visible long-stream sentinel content, scoped Stop affordance during active streaming, Agentic chat bounds, workbench visibility, message row horizontal containment, page horizontal overflow absence, message viewport scrollability, and message viewport/input separation across wide and narrow desktop viewports. +- Remaining blocked for this scenario: long reasoning, long plan, long tool result expansion/collapse, splitter drag bounds, manual scroll-position behavior, Agentic/Classic round trip content preservation, and no-fatal-text checks for the long-rich path. + +## Pass / Fail Judgment + +- **PASS** - dense Agentic chat content remains readable and layout-stable across resize, scroll, expansion, and layout switching. +- **BLOCKED** - the run lacks interactive profile, the required mock ACP agent fixture pass, a combined long-rich fixture for one-pass assertions, or stable layout selectors. +- **FAIL** - content overlaps controls, splitter bounds fail, scrolling breaks, or layout switching loses the chat surface. diff --git a/test/bdd/acp-chat-agentic-permission-during-send.scenario.md b/test/bdd/acp-chat-agentic-permission-during-send.scenario.md new file mode 100644 index 0000000000..c80553e20f --- /dev/null +++ b/test/bdd/acp-chat-agentic-permission-during-send.scenario.md @@ -0,0 +1,43 @@ +# Scenario: ACP Chat Agentic Permission During Send - Dialog, Badge, Recovery + +**Trigger:** `packages/ai-native/src/browser/acp/permission-bridge.service.ts`, `packages/ai-native/src/browser/acp/permission-dialog-container.tsx`, `packages/ai-native/src/browser/chat/chat.view.acp.tsx`, or `packages/ai-native/src/node/acp/permission-routing.service.ts` + +**Layer:** `runtime-ui` **Required profile:** `full` **Fixtures:** Agentic startup has passed, the mock ACP agent is configured as `node test/bdd/fixtures/acp-agent/mock-acp-agent.mjs --fixture=permission` for pending-dialog assertions, a `--fixture=stream-rich` pass is available for normal-send recovery checks, active session has stable permission dialog selectors, and a fresh MCP session is connected. A real LLM-backed ACP agent/prompt combination may be used only when it reliably triggers a visible permission request. **Workspace mutation:** None. **Automation status:** Converted to `tools/playwright/src/tests/permission-dialog.test.ts` for the deterministic `permission` fixture, full WebMCP profile, active badge/count observability, visible close/reject dismissal, and post-dismiss editable input. Live-agent runs may cover observable permission flow only when the prompt/agent reliably triggers permission. + +## Given + +- Agentic AI Chat is visible and the mock `permission` fixture can trigger a pending permission during a send. +- Permission decisions are performed only through visible browser UI. +- No ACP/WebMCP tool is used to approve or reject the permission request. + +## When + +1. Record `acp_chat_get_permission_state({})` before send. +2. Send the deterministic permission prompt through the Agentic input. +3. Wait until the permission dialog is visible while the request is still active. +4. Record active dialog count, history badge count, active session id, input disabled state, and visible dialog text presence. +5. Click the visible Reject or close control. +6. Wait until the dialog is dismissed and the input is editable. +7. Record `acp_chat_get_permission_state({})`, visible error/recovery UI, row counts, and history badge state. +8. If the permission fixture supports a non-permission follow-up in the same process, send it in the same session. Otherwise, restart the mock agent with `--fixture=stream-rich` and record normal-send recovery as a separate fixture pass. + +## Then + +- Pending permission is visible in both browser dialog UI and permission count metadata. +- The active chat/session badge is scoped to the session that requested permission. +- Dismissing permission through UI clears the active dialog count. +- The input does not stay disabled after permission dismissal. +- The rejected send leaves a recoverable visible state and does not create an empty duplicate session. +- A later normal send succeeds in the same session when the fixture supports per-prompt permission branching; otherwise the separate `stream-rich` recovery pass proves the UI can recover to normal send behavior after fixture reset. +- Permission state responses do not expose request content, file contents, approval options, or hidden decision tools. + +## Live Agent Execution + +- A real LLM-backed ACP agent may verify permission dialog observability, scoped badge/count metadata, UI-only dismissal, recovery, and metadata-only permission state when the live prompt reliably opens a dialog. +- Live-agent mode must not assert permission request body text, file contents, model-selected tool arguments, or generated recovery content. If no permission dialog appears, record the permission portion as blocked instead of passing it from a normal response. + +## Pass / Fail Judgment + +- **PASS** - permission during Agentic send is observable, dismissible through UI, scoped to the active session, and recoverable. +- **BLOCKED** - the run lacks full profile, the mock ACP agent `permission` fixture, or stable Reject/close selector. +- **FAIL** - permission content leaks through tools, the dialog cannot be dismissed, badges drift, or the chat remains stuck after dismissal. diff --git a/test/bdd/acp-chat-agentic-reload-during-stream.scenario.md b/test/bdd/acp-chat-agentic-reload-during-stream.scenario.md new file mode 100644 index 0000000000..91da7ad0f9 --- /dev/null +++ b/test/bdd/acp-chat-agentic-reload-during-stream.scenario.md @@ -0,0 +1,47 @@ +# Scenario: ACP Chat Agentic Reload During Stream - Mid Request Recovery + +**Trigger:** `packages/ai-native/src/browser/chat/chat.view.acp.tsx`, `packages/ai-native/src/browser/chat/chat.internal.service.acp.ts`, `packages/ai-native/src/browser/chat/acp-session-provider.ts`, or `packages/ai-native/src/browser/chat/chat-manager.service.acp.ts` + +**Layer:** `runtime-ui` **Required profile:** `interactive` **Fixtures:** Agentic startup has passed, the mock ACP agent is configured as `node test/bdd/fixtures/acp-agent/mock-acp-agent.mjs --fixture=long-stream` with enough ticks/delay to reload while streaming, a `--fixture=stream-rich` pass is available for post-reload success recovery, the session provider is reload-safe, and a fresh MCP session runs in a profile exposing the required `acp_chat` tools. A real LLM-backed ACP agent may be used only when it reliably streams long enough for live reload coverage. **Workspace mutation:** None. **Automation status:** Partially converted to deterministic Playwright coverage in `tools/playwright/src/tests/acp-chat-agentic-reload-during-stream.test.ts` using `fixture=long-stream` and `profile=interactive`; remaining post-reload success/state assertions require a deterministic success fixture pass. + +## Given + +- Agentic AI Chat is visible. +- The mock `long-stream` fixture can keep a stream active long enough for a browser reload. + +## When + +1. Send a deterministic long-stream prompt. +2. Wait until the user row and active assistant row are visible. +3. Record active session id and visible loading state. +4. Reload the page without changing the workspace URL. +5. Wait for Common Preflight readiness and Agentic AI Chat recovery. +6. Record recovered session selection, row counts, loading state, input state, and visible recovery/error text. +7. Send a deterministic successful prompt after reload. +8. Record final state and `acp_chat_get_session_state({})`. + +## Then + +- Reload keeps the page on the same workspace and restores Agentic layout. +- The previous active session is either safely restored or a structured recoverable state is shown. +- The UI does not duplicate the pre-reload user row or create phantom empty sessions. +- No spinner remains forever after reload. +- A new prompt can be sent after recovery. +- State tools remain metadata-only and diagnostics do not leak raw MCP tokens. + +## Live Agent Execution + +- A real LLM-backed ACP agent may verify that reload during a visible active stream returns the IDE to a usable Agentic chat surface and keeps state tools metadata-only. +- Live-agent mode must not assert whether the model resumes, cancels, or completes the interrupted answer, nor exact restored assistant content. If no active stream is observable before reload, record the live-agent reload assertion as blocked. + +## Deterministic Playwright Coverage + +- `tools/playwright/src/tests/acp-chat-agentic-reload-during-stream.test.ts` runs `loadAcpBddFixtureWorkbench({ fixture: 'long-stream', profile: 'interactive' })`. +- Covered: visible active long-stream sentinel before reload, scoped Stop affordance before reload, Common Preflight recovery after reload, Agentic chat heading recovery, scoped Send affordance recovery, no scoped Stop affordance after reload, and editable input recovery. +- Remaining blocked for this scenario: deterministic successful prompt after reload, recovered session id/row-count assertions, duplicate/phantom-session checks, and metadata-only session-state assertions. + +## Pass / Fail Judgment + +- **PASS** - mid-stream reload recovers to a usable Agentic chat state and allows a new send without duplicates or stuck loading. +- **BLOCKED** - the run lacks interactive profile or the mock ACP agent `long-stream` reload fixture. +- **FAIL** - reload loses Agentic layout, duplicates messages, leaves permanent loading, or prevents future sends. diff --git a/test/bdd/acp-chat-agentic-rich-history-restore.scenario.md b/test/bdd/acp-chat-agentic-rich-history-restore.scenario.md new file mode 100644 index 0000000000..51bf0172bd --- /dev/null +++ b/test/bdd/acp-chat-agentic-rich-history-restore.scenario.md @@ -0,0 +1,42 @@ +# Scenario: ACP Chat Agentic Rich History Restore - Complex Response Replay + +**Trigger:** `packages/ai-native/src/browser/chat/chat.view.acp.tsx`, `packages/ai-native/src/browser/chat/acp-session-provider.ts`, `packages/ai-native/src/browser/chat/chat-manager.service.acp.ts`, or `packages/ai-native/src/browser/model/msg-history-manager.ts` + +**Layer:** `runtime-ui` **Required profile:** `interactive` **Fixtures:** The mock ACP agent is configured as `node test/bdd/fixtures/acp-agent/mock-acp-agent.mjs --fixture=history`; it seeds at least two sessions, emits bounded rich replay updates on `session/load`, and emits `stream-rich` style content after a deterministic prompt so one session can contain completed content, reasoning, plan, and tool-call result updates. A real LLM-backed ACP agent may be used only for live restore smoke coverage. A fresh MCP session runs in a profile exposing `acp_chat_list_sessions`. **Workspace mutation:** None. **Automation status:** Converted to `tools/playwright/src/tests/acp-chat-agentic-rich-history-restore.test.ts` with `fixture=history`, `profile=interactive`, deterministic session switching, bounded reload recovery, and metadata-only state/list assertions. + +## Given + +- Agentic AI Chat is visible. +- At least one ACP session has a completed deterministic rich response produced by the mock `history` fixture after a prompt. +- The mock `history` fixture can reload the same session after page reload or session switching. + +## When + +1. Open the session that contains the completed rich response. +2. Record visible user rows, assistant rows, reasoning UI, plan content, tool-call cards, and expanded tool result state. +3. Open another ACP session, then switch back to the rich-response session from history. +4. Record the same visible elements again. +5. Reload the page without changing the workspace URL. +6. Wait for Agentic AI Chat and history to recover. +7. Reopen the rich-response session if needed and record restored rows/cards. +8. Call `acp_chat_get_session_state({})` and, if exposed, `acp_chat_list_sessions({})`. + +## Then + +- Switching away and back restores the same active session id and safe title. +- The user row and final assistant row are restored once, without duplicate rows. +- Completed reasoning, plan content, and tool-call result remain associated with the same assistant response. +- Expanded/collapsed UI state may reset, but the underlying tool-call card and result remain visible after expansion. +- Reload does not create an empty duplicate session and does not leave the recovered chat in loading state. +- State and list tools expose metadata only. Safe titles are allowed, but rich message bodies and tool results are not. + +## Live Agent Execution + +- A real LLM-backed ACP agent may verify that existing live sessions can be reopened after switching or reload and that state/list tools remain metadata-only. +- Live-agent mode must not assert exact restored user/assistant text, reasoning, plan, tool result content, or generated titles. Complex replay and duplicate-row hardening remain deterministic-fixture only. + +## Pass / Fail Judgment + +- **PASS** - complex Agentic response history survives session switching and reload without duplicates, stale loading, or metadata leaks. +- **BLOCKED** - the run lacks interactive profile, the mock ACP agent `history` rich-history fixture, or at least two sessions. +- **FAIL** - reload/switch loses rich response structure, duplicates rows/cards, drifts session selection, or leaks content through state/list tools. diff --git a/test/bdd/acp-chat-agentic-session-isolation.scenario.md b/test/bdd/acp-chat-agentic-session-isolation.scenario.md new file mode 100644 index 0000000000..f25ab91d43 --- /dev/null +++ b/test/bdd/acp-chat-agentic-session-isolation.scenario.md @@ -0,0 +1,43 @@ +# Scenario: ACP Chat Agentic Session Isolation - Concurrent Status and Updates + +**Trigger:** `packages/ai-native/src/browser/chat/chat.view.acp.tsx`, `packages/ai-native/src/browser/chat/chat.internal.service.acp.ts`, `packages/ai-native/src/browser/chat/chat-manager.service.acp.ts`, or `packages/ai-native/src/node/acp/acp-agent.service.ts` + +**Layer:** `runtime-ui` **Required profile:** `interactive` **Fixtures:** The mock ACP agent uses `--fixture=history` for two deterministic ACP sessions, `--fixture=long-stream` for a controlled active stream, and `--fixture=stream-rich` for completed stream assertions. A real LLM-backed ACP agent may be used only for live two-session smoke coverage. History surface is available, and a fresh MCP session runs in a profile exposing `acp_chat_get_session_state` and `acp_chat_list_sessions`. **Workspace mutation:** None. **Automation status:** History-backed isolation is converted to `tools/playwright/src/tests/acp-chat-agentic-session-isolation.test.ts` with `fixture=history`, `profile=interactive`, deterministic per-session send/switch assertions, and metadata-only state/list checks. Concurrent long-stream isolation remains blocked until a fixture pass can preserve an active stream while switching sessions. + +## Given + +- Agentic AI Chat is visible. +- Session A can stream for a controlled duration with the mock `long-stream` fixture. +- Session B can complete a short deterministic response with the mock `stream-rich` fixture, or the subcase is recorded as blocked if the harness cannot switch fixtures while preserving both sessions. + +## When + +1. Select or create Session A. +2. Start a long-running deterministic stream in Session A. +3. Switch to Session B from the history surface while Session A is still working. +4. Send a short deterministic prompt in Session B and wait for completion. +5. Record visible rows, loading state, and current session marker. +6. Let Session A emit more stream updates while Session B remains selected. +7. Record whether Session B DOM changes. +8. Switch back to Session A and record its stream/status state. +9. Record `acp_chat_get_session_state({})` and `acp_chat_list_sessions({})`. + +## Then + +- Session B does not receive Session A content, reasoning, tool cards, status, or permission badges. +- Session A working status remains scoped to Session A while another session is selected. +- Session B can send and complete while Session A is still active or pending. +- Switching back to Session A shows only Session A rows and active status. +- Current markers and state tool active session id agree after each selection. +- List/state tools remain metadata-only. + +## Live Agent Execution + +- A real LLM-backed ACP agent may verify that two live sessions can be listed, selected, and kept visually separate while state/list tools remain metadata-only. +- Live-agent mode must not assert concurrent stream timing, exact status transitions, exact history order, or model-generated content per session. Wrong-session update isolation remains deterministic-fixture only. + +## Pass / Fail Judgment + +- **PASS** - concurrent Agentic session updates remain isolated in visible UI, history, and metadata. +- **BLOCKED** - the run lacks interactive profile, two deterministic sessions, controllable long-stream fixture, or a harness that can preserve sessions across the required fixture passes. +- **FAIL** - cross-session updates appear in the wrong chat, active markers drift, or a non-active session blocks the active session UI. diff --git a/test/bdd/acp-chat-agentic-side-entry-filter.scenario.md b/test/bdd/acp-chat-agentic-side-entry-filter.scenario.md new file mode 100644 index 0000000000..5342606619 --- /dev/null +++ b/test/bdd/acp-chat-agentic-side-entry-filter.scenario.md @@ -0,0 +1,36 @@ +# Scenario: ACP Chat Agentic Side Entry Filter - Explorer And Git Only + +**Trigger:** `packages/ai-native/src/browser/layout/tabbar.view.tsx`, `packages/main-layout/src/browser/tabbar/bar.view.tsx`, or `packages/ai-native/src/browser/layout/panel-layout.service.ts` + +**Layer:** `runtime-ui` **Required profile:** `default` **Fixtures:** IDE dev server opened on the default Playwright workspace with Common Preflight. **Workspace mutation:** None; this scenario is read-only. **Automation status:** Automated through Playwright and Chrome DevTools MCP-compatible DOM checks; no ACP agent fixture is required. + +## Given + +- Common preflight in `test/bdd/README.md` passes. +- The IDE is opened with a workspace that contains `editor.js` and `test/test.js`. +- Agent layout is available from the user-facing `View -> Panel Layout -> Agent` menu or layout selector. +- Classic layout is available for a control check. + +## When + +1. Open the default workspace. +2. Show ACP Chat with `acp_chat_show_chat_view({})` when `navigator.modelContext` exposes it. +3. Switch to Agentic layout. +4. Inspect visible Activity Bar / side entry IDs in the Agentic left tabbar. +5. Click the Explorer entry and assert the Explorer panel can open. +6. Click the Git/SCM entry and assert the SCM panel can open. +7. Switch back to Classic layout and inspect the visible left Activity Bar entries again. + +## Then + +- Agentic layout shows only the `explorer` and `scm` side entries from the standard IDE container set. +- Agentic layout does not show `search`, `debug`, or `extension` side entries. +- Explorer and Git/SCM entries remain clickable in Agentic layout. +- Classic layout still shows the standard left Activity Bar entries, including Search, Debug, and Extension Marketplace when those containers are registered. +- Debug and Extension Marketplace services are not disabled by this scenario; only the Agentic side entry UI is filtered. + +## Pass / Fail Judgment + +- **PASS** - Agentic side entries are limited to Explorer and Git/SCM, both remaining interactive, while Classic still exposes the broader standard Activity Bar set. +- **FAIL** - Agentic shows Search, Debug, Extension Marketplace, or hides Explorer/Git; Explorer/Git cannot be activated; or Classic loses the standard side entries. +- **BLOCKED** - Common Preflight fails, Agentic layout cannot be selected, or the standard left tabbar selectors are unavailable. diff --git a/test/bdd/acp-chat-agentic-startup.scenario.md b/test/bdd/acp-chat-agentic-startup.scenario.md new file mode 100644 index 0000000000..7bedb9f4cd --- /dev/null +++ b/test/bdd/acp-chat-agentic-startup.scenario.md @@ -0,0 +1,42 @@ +# Scenario: ACP Chat Agentic Startup - Default Layout and Safe Tool Surface + +**Trigger:** `packages/ai-native/src/browser/layout/ai-layout.tsx`, `packages/ai-native/src/browser/layout/panel-layout.service.ts`, `packages/ai-native/src/browser/acp/components/AcpChatViewWrapper.tsx`, `packages/ai-native/src/browser/chat/chat.view.acp.tsx`, or `packages/ai-native/src/browser/acp/webmcp-groups/acp-chat.webmcp-group.ts` + +**Layer:** `runtime-ui` **Required profile:** `default` **Fixtures:** Fresh browser profile or cleared Agentic layout storage, IDE dev server, Common Preflight, and fresh MCP session. **Workspace mutation:** None. **Automation status:** Automated through Chrome DevTools MCP plus the `opensumi-ide` MCP server. + +## Given + +- Common preflight in `test/bdd/README.md` passes through Chrome DevTools MCP. +- The IDE is opened with `ai.native.panelLayout = "agentic"` or no explicit layout preference. +- Layout storage keys `layout.ai.agentic` and `layout.state` are absent or cleared before the run. +- The MCP `opensumi-ide` server is connected with a fresh MCP session. + +## When + +1. `chrome-devtools-mcp`: Open `http://localhost:8080/?workspaceDir=`. +2. Wait until the Common Preflight browser readiness predicate passes. +3. Record layout label/preference state and bounding boxes for AI Chat, workbench, Explorer/view slot, and status bar. +4. `mcp`: `tools/list` -> record `TOOLS_DEFAULT`. +5. `mcp`: `acp_chat_show_chat_view({})`. +6. Wait until the Agentic AI Chat header/input is visible. +7. `mcp`: `acp_chat_get_session_state({})` -> record `STATE_AFTER_OPEN`. +8. `mcp`: `acp_chat_get_permission_state({})` -> record `PERMISSION_AFTER_OPEN`. +9. Record fatal UI text, retry/timeout text, and uncaught stack text. + +## Then + +- The page remains on the original workspace URL and the visible layout state is Agentic. +- AI Chat is the leftmost major column and stays within the Agentic default bounds: `640px <= AI Chat width <= 1440px`. +- Workbench width is at least `480px`, Explorer/view slot is visible or restorable, and the status bar remains visible. +- `TOOLS_DEFAULT` exposes lower-snake canonical tool names only and includes only default ACP Chat tools. +- Legacy `_opensumi/...`, older camelCase ACP Chat names, and old direct ACP mutation tools are absent and fail with tool-not-found if called as explicit negative checks. +- `acp_chat_show_chat_view({})` returns `success: true` and `{ shown: true }`. +- Opening Agentic AI Chat may leave no active session, or may expose an empty metadata-only active session. +- Session and permission state responses expose metadata/counts only. Bounded session titles are allowed, but full prompt/message bodies, assistant text, file contents, relay digest bodies, permission prompt content, and tool-call result content are not. +- No step shows fatal UI text such as `SERVICE_UNAVAILABLE`, `EXECUTION_ERROR`, uncaught stack traces, or an initialization timeout that blocks the chat view. + +## Pass / Fail Judgment + +- **PASS** - Agentic AI Chat opens as the leftmost chat surface, default ACP Chat tools are safe and canonical, and state responses are metadata-only. +- **BLOCKED** - Common Preflight, the MCP bridge, or the Agentic layout launch profile is unavailable. +- **FAIL** - Agentic layout is not usable, tool names drift, old mutation tools are exposed, or state responses leak content. diff --git a/test/bdd/acp-chat-agentic-stream-rendering.scenario.md b/test/bdd/acp-chat-agentic-stream-rendering.scenario.md new file mode 100644 index 0000000000..fa23cd9417 --- /dev/null +++ b/test/bdd/acp-chat-agentic-stream-rendering.scenario.md @@ -0,0 +1,70 @@ +# Scenario: ACP Chat Agentic Stream Rendering - Deterministic Agent Updates + +**Trigger:** `packages/ai-native/src/browser/chat/chat.view.acp.tsx`, `packages/ai-native/src/browser/components/ChatReply.tsx`, `packages/ai-native/src/browser/components/acp/ChatReply.tsx`, `packages/ai-native/src/browser/chat/chat-model.ts`, `packages/ai-native/src/browser/chat/acp-chat-agent.ts`, or `packages/ai-native/src/node/acp/acp-cli-back.service.ts` + +**Layer:** `runtime-ui` **Required profile:** `interactive` **Fixtures:** `acp-chat-agentic-startup.scenario.md` and `acp-chat-agentic-input-send.scenario.md` have passed, the mock ACP agent is configured as `node test/bdd/fixtures/acp-agent/mock-acp-agent.mjs --fixture=stream-rich` for content/reasoning/plan/tool-call assertions, a separate `--fixture=send-failure` pass covers failure recovery, optionally a real LLM-backed ACP agent covers live shell/stream smoke, a fresh MCP session runs in a profile exposing the required `acp_chat` tools, and the default `toolCall` chat component is registered. **Workspace mutation:** None. **Automation status:** Automated through Chrome DevTools MCP plus the `opensumi-ide` MCP server; live-agent runs may verify coarse stream state, but deterministic mock-agent fixtures are required for content/reasoning/plan/tool-call assertions and Playwright conversion. + +## Given + +- Agentic AI Chat is visible and focusable. +- Deterministic-fixture mode uses the mock ACP agent `stream-rich` fixture through `AcpThread`, not a live LLM response. +- Live-agent mode may use a real LLM response only for coarse shell and stream-state evidence. +- The deterministic fixture can emit stable sentinel content for browser DOM checks without relying on generated assistant text. +- The scenario validates UI rendering of converted chat progress, not the raw ACP notification JSON contract. +- ACP Chat state tools must remain metadata-only. Bounded session titles are allowed, but full prompt/message bodies, assistant text, tool-call arguments, tool results, and raw ACP JSON payloads are not. + +## When + +1. `mcp`: `acp_chat_get_session_state({})` -> record `STATE_BEFORE_STREAM`. +2. Focus the Agentic input and send the deterministic stream-rendering prompt through the UI. +3. Wait until the first user row is visible and record user row count, assistant row count, active loading state, and input disabled state. +4. The deterministic stream emits `threadStatus: working` for the active raw session id. +5. The deterministic stream emits `sessionState` metadata, including at least one stable mode/model/config update. +6. The deterministic stream emits two `reasoning` chunks with stable sentinel text. +7. Record the assistant row before clicking `Deep Thinking` and confirm reasoning sentinel text is not visible by default. +8. Click `Deep Thinking` while the response is still streaming, then wait for the next reasoning chunk and record the expanded content. +9. Click `Deep Thinking` again and confirm the reasoning sentinel text is hidden. +10. The deterministic stream emits a `plan` update converted to stable checklist or markdown text. +11. The deterministic stream emits two assistant `content` chunks that should merge into one assistant response. +12. The deterministic stream emits one `toolCall` update for a stable tool id and tool name. +13. The deterministic stream emits a second `toolCall` update with the same tool id and updated arguments. +14. The deterministic stream emits a final `toolCall` result update with the same tool id. +15. The deterministic stream emits final assistant content, `threadStatus: awaiting_prompt`, and completes. +16. Record the completed assistant row before clicking `Deep Thinking` again. +17. `mcp`: `acp_chat_get_session_state({})` -> record `STATE_AFTER_STREAM`. +18. Expand the visible tool-call card and record its tool name, arguments section, result section, and row/card count. +19. Run a separate `--fixture=send-failure` mock-agent pass after a user row has rendered. +20. Record visible error text, input focusability, loading state, and whether a retry with the successful fixture clears stale error/loading UI. + +## Then + +- Step 2 creates or activates exactly one ACP session before writing history. +- The user message appears exactly once and before the assistant response. +- The assistant stream renders as one active assistant response row and resolves to one stable final assistant row. +- `threadStatus: working` is reflected in loading or history/session status, and `threadStatus: awaiting_prompt` clears loading state when the stream finishes. +- `sessionState` updates mode/model/config controls or session metadata without adding a chat message row. +- Reasoning renders as a `Deep Thinking` toggle in the thinking UI while streaming and remains associated with the same assistant response after completion. +- Reasoning content is collapsed by default while streaming; deterministic reasoning sentinel text is not visible until the user expands `Deep Thinking`. +- Clicking `Deep Thinking` while streaming expands the current reasoning content and later reasoning chunks continue rendering inside the same expanded response. +- Clicking `Deep Thinking` again collapses the reasoning content and hides the deterministic sentinel text. +- If the user leaves `Deep Thinking` collapsed, reasoning content remains hidden after completion. +- Plan content renders as normal assistant markdown/checklist content in the same response flow. +- Assistant content chunks merge in order without duplicate markdown blocks or duplicate assistant rows. +- The first tool call renders one tool-call card with the stable tool name. +- The second tool-call update with the same id updates the existing card instead of adding a duplicate card. +- The final tool-call result update makes the existing card show a result-ready state and a result section after expansion. +- The input is disabled only while session creation or streaming is active and becomes editable after success or failure. +- The failure fixture shows a user-visible error, clears stale loading state, preserves the user row, and allows a successful retry without duplicating stale assistant/tool rows. +- `STATE_AFTER_STREAM` returns `success: true` and remains metadata-only; it may include bounded title metadata, but must not include full prompt/message bodies, assistant text, reasoning text, plan content, tool arguments, tool results, raw ACP JSON, MCP tokens, or permission content. +- No step uses or expects legacy direct ACP tools such as `acp_sendMessage`, `acp_cancelRequest`, or older camelCase ACP Chat tool names. + +## Live Agent Execution + +- A real LLM-backed ACP agent may verify that sending creates one user row, one active assistant row, visible loading/streaming state, stop visibility when available, completion recovery, and metadata-only state. +- Live-agent mode must not assert assistant markdown, reasoning text, plan text, token/chunk order, tool-call arguments/results, or exact completion text. Those assertions remain deterministic-fixture only and should be omitted explicitly from live-agent evidence. + +## Pass / Fail Judgment + +- **PASS** - deterministic ACP stream progress renders content, reasoning, plan, tool-call updates, session state, completion, and failure recovery in the Agentic UI without duplicate rows/cards or state-tool content leaks. +- **BLOCKED** - the run lacks interactive profile, the mock ACP agent `stream-rich`/`send-failure` fixture passes, the default `toolCall` chat component, or a supported browser/MCP execution surface. +- **FAIL** - converted stream updates do not render, duplicate assistant rows or tool cards appear, loading/input state gets stuck, retry leaves stale error/tool UI, or ACP Chat state tools leak message/tool/raw protocol content. diff --git a/test/bdd/acp-chat-agentic-theme-persistence.scenario.md b/test/bdd/acp-chat-agentic-theme-persistence.scenario.md new file mode 100644 index 0000000000..c163add065 --- /dev/null +++ b/test/bdd/acp-chat-agentic-theme-persistence.scenario.md @@ -0,0 +1,40 @@ +# Scenario: ACP Chat Agentic Theme Persistence - Layout and Visual State + +**Trigger:** `packages/ai-native/src/browser/layout/ai-layout.tsx`, `packages/ai-native/src/browser/layout/panel-layout.service.ts`, `packages/ai-native/src/browser/chat/chat.view.acp.tsx`, or `packages/ai-native/src/browser/acp/components/AcpChatViewHeader.tsx` + +**Layer:** `runtime-ui` **Required profile:** `default` **Fixtures:** Fresh browser profile or cleared Agentic layout storage, IDE dev server, Common Preflight, optional deterministic chat session, and optionally a real LLM-backed ACP agent for live populated-chat visual smoke coverage. **Workspace mutation:** None. **Automation status:** Automated through Chrome DevTools MCP; blocked if theme/layout preference controls are unavailable. Live-agent content is optional and must not gate theme/layout persistence assertions. + +## Given + +- The IDE can start in Agentic layout. +- Theme and panel layout preferences can be changed through supported user-facing UI or preference APIs. + +## When + +1. Open the workspace in Agentic layout. +2. Record layout label, AI Chat/workbench geometry, theme class or visible theme marker, and chat view visibility. +3. Switch theme from the current theme to another supported theme. +4. Record AI Chat header, input, message list, history, and tool-card visual readability. +5. Resize Agentic AI Chat within bounds. +6. Reload the page. +7. Record whether Agentic layout, theme, chat visibility, and resized geometry persist or safely restore to supported defaults. +8. Switch Agentic to Classic and back to Agentic, then record final visual state. + +## Then + +- Theme changes do not hide or make unreadable the Agentic chat header, input, history, or message rows. +- Agentic layout remains the selected layout after reload unless the profile explicitly resets preferences. +- AI Chat and workbench geometry remain within supported bounds after reload. +- Switching Classic back to Agentic restores the leftmost AI Chat layout. +- No visible text overlaps, zero-size chat slot, or fatal startup text appears. + +## Live Agent Execution + +- A real LLM-backed ACP agent may populate the chat surface before theme, resize, reload, and layout round-trip checks to provide live visual evidence. +- Live-agent mode must not assert generated assistant text or exact restored message content. Theme readability, preference persistence, geometry, and layout round-trip checks remain model-output independent. + +## Pass / Fail Judgment + +- **PASS** - Agentic layout and theme state remain visually usable across theme change, resize, reload, and layout round trip. +- **BLOCKED** - the run lacks default profile, theme/layout controls, or browser storage access needed for validation. +- **FAIL** - theme breaks readability, Agentic preference is lost, geometry escapes bounds, or the chat slot becomes unusable. diff --git a/test/bdd/acp-chat-session-storage.scenario.md b/test/bdd/acp-chat-session-storage.scenario.md new file mode 100644 index 0000000000..e24c5fd22b --- /dev/null +++ b/test/bdd/acp-chat-session-storage.scenario.md @@ -0,0 +1,65 @@ +# Scenario: ACP Chat Session Storage - Provider, Activation, Fallback, Cleanup + +**Trigger:** `packages/ai-native/src/browser/chat/chat-manager.service.acp.ts` or `packages/ai-native/src/browser/chat/chat.internal.service.acp.ts` + +**Layer:** `node-contract` **Required profile:** `default` **Fixtures:** Mock ACP chat provider, permission bridge, and deterministic session models. **Workspace mutation:** None. **Automation status:** Automated service contract spec; browser runtime checks are covered by the split Agentic scenarios. + +## Given + +- The browser runs with `supportsAgentMode=true`. +- The ACP session provider can create, list, load, and save sessions. +- The permission bridge is available. + +## When + +### Part A - Load Session List + +1. Start `AcpChatManagerService`. +2. Provider returns more than 20 sessions. +3. Some returned sessions are already active in memory. +4. Provider returns sessions with raw ids and browser-prefixed `acp:` ids that refer to the same underlying session. + +### Part B - Create Session + +5. `AcpChatInternalService.createSessionModel()` is called. +6. Provider returns a new ACP session with `extension.availableCommands`. +7. Provider returns a session with no title, no history, and no request records. + +### Part C - Activate Existing Session + +8. Activate a session already loaded with history. +9. Activate a session not loaded in memory. +10. Activate a missing or failing session. +11. Activate a session whose available commands changed since it was last loaded. + +### Part D - Clear And Dispose + +12. Clear the active session. +13. Dispose `AcpChatInternalService`. +14. Emit a provider/session change event after disposal. + +### Part E - Local Fallback + +15. Provider setup fails or no ACP provider can handle mode `acp`. +16. `fallbackToLocal()` is called. + +## Then + +- Session list loading keeps at most the newest 20 sessions. +- Loading does not overwrite active in-memory sessions. +- Raw and prefixed ids are normalized for equality so the same ACP session does not appear twice. +- Sessions without history are retained only when their id starts with `acp:`. +- Creating a session stores the model, starts listening for changes, propagates available commands, fires session model change events, assigns safe fallback title metadata when needed, and sets the permission bridge active session to the raw id. +- Activating a loaded session avoids unnecessary provider load calls. +- Activating an unloaded session loads it through ACP, updates available commands, fires session model changes, and updates the permission bridge raw active session id. +- Changed available commands replace the previous command set instead of appending stale commands. +- Missing or failed loads create a new session and surface an informational message instead of leaving the UI without an active session. +- Clearing the active session clears permission dialogs for the raw active session id before creating or selecting the replacement session. +- Dispose clears the permission bridge active session. +- Provider/session events after disposal do not mutate the disposed service or re-register listeners. +- Local fallback clears ACP sessions, switches to the local provider, and reloads the session list. + +## Pass / Fail Judgment + +- **PASS** - ACP chat storage preserves session bounds, active-session observability, command propagation, and permission cleanup. +- **FAIL** - old sessions exceed the cap, active session ids drift between `acp:` and raw ids, or permission dialogs survive session clearing. diff --git a/test/bdd/acp-chat.scenario.md b/test/bdd/acp-chat.scenario.md new file mode 100644 index 0000000000..41171e7b9d --- /dev/null +++ b/test/bdd/acp-chat.scenario.md @@ -0,0 +1,69 @@ +# Scenario: ACP Chat Default Surface - Open View and Observe Safe State + +**Trigger:** `packages/ai-native/src/browser/acp/**` or `packages/ai-native/src/node/acp/opensumi-mcp-http-server.ts` + +**Layer:** `runtime-ui` **Required profile:** `default` **Fixtures:** IDE dev server, fresh MCP session, and Common Preflight. **Workspace mutation:** None. **Automation status:** Automated smoke scenario through Chrome DevTools MCP plus the `opensumi-ide` MCP server. + +## Given + +- Common preflight in `test/bdd/README.md` passes through Chrome DevTools MCP. +- The MCP `opensumi-ide` server is connected. +- `tools/list` includes: + - `opensumi_discover_capabilities` + - `opensumi_enable_capability_group` + - `opensumi_invoke_capability_tool` + - `acp_chat_get_session_state` + - `acp_chat_get_permission_state` + - `acp_chat_show_chat_view` +- `tools/list` does not include legacy ACP direct tools: + - `acp_sendMessage` + - `acp_createSession` + - `acp_switchSession` + - `acp_clearSession` + - `acp_cancelRequest` + - `acp_handlePermissionDialog` +- `tools/list` does not include older camelCase ACP Chat names: + - `acp_chat_getSessionState` + - `acp_chat_getPermissionState` + - `acp_chat_showChatView` + +## When + +1. `mcp`: `opensumi_discover_capabilities({ task: "observe acp chat session state", includeDisabled: true })` +2. `mcp`: `acp_chat_show_chat_view({})` +3. `chrome-devtools-mcp-wait`: wait until the ACP chat view is visible. +4. `mcp`: `acp_chat_get_session_state({})` -> record `SESSION_STATE`. +5. `mcp`: `acp_chat_get_permission_state({})` -> record `PERMISSION_STATE`. +6. `mcp`: call `acp_chat_getSessionState({})` as a negative compatibility check. +7. `chrome-devtools-mcp-evaluate`: record visible ACP chat text and fatal error text. + +## Then + +- Step 1 returns a group named `acp_chat` with default exposed tools. +- Step 2 returns `success: true` and `{ shown: true }`. +- Step 3 sees the chat view, for example a visible `AI Assistant` heading or ACP chat input area. +- Step 4 returns `success: true`. +- If `SESSION_STATE.result.active === true`, `SESSION_STATE.result.session` contains metadata only: + - `sessionId` + - `rawSessionId` + - `title` + - `modelId` + - `threadStatus` + - `requestCount` + - `historyMessageCount` + - `slicedMessageCount` + - `hasPendingPermission` +- If no active session exists, Step 4 returns `{ active: false, session: null }`. +- Step 4 may contain bounded session title metadata, but must not contain full prompt/message bodies, assistant response text, or tool-call result content. +- Step 5 returns only permission counts and active session id: + - `activeDialogCount` + - `activeSessionId` + - `pendingCountExcludingActive` +- Step 5 must not expose permission prompt content, affected file content, or any approval/rejection action. +- Step 6 fails with a standard tool-not-found style MCP error. +- Step 7 does not show fatal UI text such as `SERVICE_UNAVAILABLE`, `EXECUTION_ERROR`, or uncaught stack traces. + +## Pass / Fail Judgment + +- **PASS** - default tools are available, legacy tools are absent, the chat view opens, and state responses are metadata-only. +- **FAIL** - any legacy direct ACP tool is exposed, the chat view cannot open, or state responses leak message/response/tool result content outside allowed title metadata. diff --git a/test/bdd/acp-client-handlers.scenario.md b/test/bdd/acp-client-handlers.scenario.md new file mode 100644 index 0000000000..2c845c584a --- /dev/null +++ b/test/bdd/acp-client-handlers.scenario.md @@ -0,0 +1,70 @@ +# Scenario: ACP Client Handlers - File System and Terminal Delegation + +**Trigger:** `packages/ai-native/src/node/acp/handlers/file-system.handler.ts`, `packages/ai-native/src/node/acp/handlers/terminal.handler.ts`, or `packages/ai-native/src/node/acp/acp-thread.ts` + +**Layer:** `node-contract` **Required profile:** `default` **Fixtures:** Temporary file-system and terminal handler fixtures owned by one ACP session. **Workspace mutation:** Temporary fixture resources only. **Automation status:** Automated contract spec; runtime WebMCP file/terminal coverage lives in `webmcp-ide-capability-groups.scenario.md`. + +## Given + +- The ACP thread has initialized and created a session. +- `AcpFileSystemHandler` is configured with the workspace directory. +- `AcpTerminalHandler` is available. +- The agent can call ACP client methods: + - `readTextFile` + - `writeTextFile` + - `createTerminal` + - `terminalOutput` + - `waitForTerminalExit` + - `killTerminal` + - `releaseTerminal` + +## When + +### Part A - File Reads + +1. Agent calls `readTextFile` with a workspace-relative text file path. +2. Agent calls `readTextFile` with `line` and `limit`. +3. Agent calls `readTextFile` with a missing file. +4. Agent calls `readTextFile` with an absolute path outside the workspace. +5. Agent calls `readTextFile` for a file larger than `maxFileSize`. +6. Agent calls `readTextFile` with `../` traversal and URL-encoded traversal variants. + +### Part B - File Writes + +7. Agent calls `writeTextFile` for a new workspace-relative file path. +8. Agent calls `writeTextFile` for an existing file. +9. Agent calls `writeTextFile` for a nested path whose parent folder does not exist. +10. Agent calls `writeTextFile` with a path traversal outside the workspace. +11. Agent calls `writeTextFile` with binary-looking or very large text content. + +### Part C - Terminal Lifecycle + +12. Agent calls `createTerminal` with a short command and session id. +13. Agent calls `terminalOutput` with the owning session id. +14. Agent calls `terminalOutput` with a small limit and then a large limit. +15. Agent calls `waitForTerminalExit` before and after the command exits. +16. Agent calls `terminalOutput`, `waitForTerminalExit`, `killTerminal`, or `releaseTerminal` with a different session id. +17. Agent calls `releaseTerminal` twice for the same terminal. +18. Agent creates two terminals for a session and `releaseSessionTerminals(sessionId)` is called during session disposal. + +## Then + +- File reads resolve paths relative to the configured workspace. +- `line` and `limit` return the expected bounded slice. +- Missing files return `RESOURCE_NOT_FOUND` style errors. +- Workspace escape attempts return an invalid-path error and do not read or write outside the workspace. +- Oversized files fail before content is returned. +- File writes create parent folders when needed and update existing files through the file service. +- Large writes are either rejected with a structured error or written through the same file service path; they must not block the event loop with unbounded synchronous writes. +- Terminal creation returns a `terminalId` owned by the raw ACP session id. +- Terminal output returns output text, truncation state, and exit status only for the owning session. +- Terminal output respects requested bounds/caps and reports truncation when output is cut. +- Session mismatch returns an error for all terminal operations that target an existing terminal. +- `releaseTerminal` is idempotent for already released or missing terminals. +- `releaseSessionTerminals` releases only terminals owned by the target session. +- Handler errors thrown through `AcpThread` become agent-call errors instead of silent successes. + +## Pass / Fail Judgment + +- **PASS** - file and terminal client hooks are workspace/session scoped, bounded, and cleaned up with session disposal. +- **FAIL** - path traversal succeeds, terminal ownership is bypassed, output is unbounded, or released terminals remain attached to a disposed session. diff --git a/test/bdd/acp-debug-log.scenario.md b/test/bdd/acp-debug-log.scenario.md new file mode 100644 index 0000000000..de8a4c04ca --- /dev/null +++ b/test/bdd/acp-debug-log.scenario.md @@ -0,0 +1,76 @@ +# Scenario: ACP Debug Log - Protocol Trace, Entry Bounds, and Viewer + +**Trigger:** `packages/ai-native/src/node/acp/acp-debug-log.ts`, `packages/ai-native/src/browser/acp/debug-log/acp-debug-log.contribution.ts`, or `packages/ai-native/src/browser/acp/debug-log/acp-debug-log.view.tsx` + +**Layer:** `runtime-ui` **Required profile:** `full` with ACP debug logging enabled. **Fixtures:** ACP debug log store, one thread driven by `node test/bdd/fixtures/acp-agent/mock-acp-agent.mjs --fixture=stream-rich` or synthetic store records that emit protocol lines, optionally a real LLM-backed ACP agent for live raw-log viewer smoke coverage, and the browser debug-log contribution. **Workspace mutation:** None. **Automation status:** Automated with store-level assertions and Chrome DevTools MCP viewer checks. Live-agent raw-log evidence must be redacted and must not include real secrets. Sensitive-data redaction checks are blocked until the product exposes a redacted render/copy contract. + +## Given + +- ACP debug logging is enabled by the active test profile. +- At least one ACP thread has started through the mock ACP agent or synthetic store harness and can write stdout/stderr protocol lines. +- The IDE command registry contains `ai.native.acp.openDebugLog`. +- Common preflight in `test/bdd/README.md` passes when validating the browser viewer. + +## When + +### Part A - Store Recording + +1. Record an outgoing JSON-RPC line with `agentId`, `threadId`, and no `sessionId`. +2. Record an incoming JSON-RPC line with an explicit raw ACP `sessionId`. +3. Record a stderr line that is not valid JSON. +4. Record one chunk containing two newline-delimited protocol messages and one trailing partial message through `createLineRecorder`. +5. Complete the trailing partial message with a later chunk. + +### Part B - Session Backfill and Bounds + +6. Call `setThreadSessionId(threadId, rawSessionId)` after earlier entries were recorded without a session id. +7. Record more than 2000 entries for the same thread. +8. Call `getEntries()`, mutate the returned first entry locally, and call `getEntries()` again. +9. Call `clear()`. + +### Part C - Viewer + +10. Execute `ai.native.acp.openDebugLog`. +11. Chrome DevTools MCP waits for an editor tab named `ACP Debug Log`. +12. Click Refresh. +13. Click Copy All when entries exist. +14. Click Clear. +15. Let the auto-refresh timer tick at least once. + +### Part D - Sensitive Transport Data Audit + +16. Run this part only when a redacted debug-log render/copy contract is implemented. +17. Create a session where the built-in `opensumi-ide` MCP server is injected. +18. Open the debug log viewer and copy all entries. +19. Search the copied log text for: + - raw MCP URL paths matching `/mcp/[a-f0-9]{32}` + - known API token/key patterns + - full relay digest bodies or permission prompt content + +## Then + +- Valid JSON lines populate `payload`; non-JSON stderr lines keep `payload` empty and preserve raw text. +- Empty lines are ignored by `createLineRecorder`. +- Partial chunks are not recorded until a newline completes the message. +- `setThreadSessionId` backfills earlier entries for the same thread that did not yet have a session id. +- The store keeps only the newest 2000 entries. +- `getEntries()` returns defensive copies; local mutation of a returned entry does not mutate the store. +- `clear()` resets the entry list and starts ids from `1` for the next record. +- The viewer opens as a normal editor component, not as a modal that blocks chat usage. +- Refresh reloads entries from `IAIBackService.getAcpDebugLog`. +- Clear calls `IAIBackService.clearAcpDebugLog` and updates the UI to the empty state. +- Copy All is disabled when there are no entries and writes the rendered log when entries exist. +- Auto-refresh does not duplicate existing entries or reset scroll/focus unexpectedly. +- Current debug-log rendering is raw. Tests must use synthetic protocol lines and must not inject real secrets. +- When a redacted render/copy contract exists, Part D must verify that debug log UI does not expose unredacted MCP bridge tokens, API keys, full relay digests, or permission prompt contents. + +## Live Agent Execution + +- A real LLM-backed ACP agent may be used only for live viewer smoke coverage: entries appear, refresh/copy/clear controls work, and session/thread metadata is visible. +- Live-agent mode must not be used for store bounds, defensive-copy, partial-line parsing, or redaction pass/fail assertions. Any captured live logs must redact raw prompts, assistant text, API keys, MCP tokens, permission content, relay digests, and tool results. + +## Pass / Fail Judgment + +- **PASS** - ACP debug logging captures useful protocol traces, keeps the newest 2000 entries, preserves session/thread metadata, and presents a usable raw viewer for synthetic test data. +- **BLOCKED** - the run schedules Part D before the product exposes redacted debug-log rendering/copying. +- **FAIL** - entry counts grow unbounded, partial lines become corrupt entries, session ids are not backfilled, the viewer cannot refresh/clear/copy correctly, or the redaction audit runs and copied logs contain unredacted MCP tokens or sensitive content. diff --git a/test/bdd/acp-error-and-recovery.scenario.md b/test/bdd/acp-error-and-recovery.scenario.md new file mode 100644 index 0000000000..87be79d812 --- /dev/null +++ b/test/bdd/acp-error-and-recovery.scenario.md @@ -0,0 +1,70 @@ +# Scenario: ACP Error and Recovery - Structured Failures Without Stale UI State + +**Trigger:** `packages/ai-native/src/node/acp/acp-error.ts`, `packages/ai-native/src/node/acp/acp-agent.service.ts`, `packages/ai-native/src/node/acp/acp-cli-back.service.ts`, `packages/ai-native/src/browser/acp/webmcp-utils.ts`, or `packages/ai-native/src/browser/acp/components/AcpChatViewWrapper.tsx` + +**Layer:** `node-contract` **Required profile:** `full` for complete WebMCP and browser recovery coverage. **Fixtures:** The mock ACP agent provides deterministic ACP process failures through `--fixture=create-failure`, `--fixture=load-failure`, `--fixture=send-failure`, `--fixture=auth-required`, `--fixture=config-failure`, and `--fixture=process-exit`; node service, browser provider, and WebMCP registry failures use their existing targeted harnesses. **Workspace mutation:** None. **Automation status:** Node/service contract coverage remains in focused Jest suites. The visible browser recovery portion is converted in `tools/playwright/src/tests/acp-chat-agentic-error-taxonomy.test.ts` for deterministic create, load, send, auth-required, config, and process-exit failure passes plus follow-up `stream-rich` recovery. + +## Given + +- ACP agent mode is enabled. +- The test harness can force deterministic failures from the mock ACP process, node service, browser provider, and WebMCP tool registry. +- Common preflight in `test/bdd/README.md` passes for browser recovery checks. +- The run records browser console errors, MCP tool responses, chat model loading flags, and visible fatal UI text through Chrome DevTools MCP. + +## When + +### Part A - Node Error Normalization + +1. Pass a native `Error("plain failure")` through `normalizeAcpError`. +2. Pass a string error through `normalizeAcpError`. +3. Pass an ACP SDK error object with `message`, `code`, and `data`. +4. Pass an object with nested `{ error: { message } }`. +5. Pass a circular object. + +### Part B - Service Operation Failures + +6. Force `createSession` to fail with the mock `create-failure` fixture after a thread is allocated but before a session id is returned. +7. Force `loadSession` to fail with the mock `load-failure` fixture for a historical session and then succeed through `loadSessionOrNew`. +8. Force `sendMessage` to fail with the mock `send-failure` fixture while the thread is `working`. +9. Call mode/config/fork/resume/close/model operations with a missing raw session id. +10. Dispose a session while a pending load is still in flight. + +### Part C - WebMCP Error Shape + +11. Call `acp_chat_get_session_state` when `IChatInternalService` is unavailable. +12. Call `acp_chat_prepare_session_digest({ sourceSessionId: "" })`. +13. Call a WebMCP tool whose implementation throws an error containing `token=secret-value` and an `sk-...` style token. +14. Call `opensumi_invoke_capability_tool` with invalid nested arguments. + +### Part D - Browser Recovery + +15. Start the IDE with `aiBackService.ready()` rejecting before ACP chat initialization. +16. Open or show the ACP chat view. +17. Trigger a deterministic create-session failure from the UI with the mock `create-failure` fixture. +18. Trigger a deterministic send failure from the UI with the mock `send-failure` fixture. +19. Trigger deterministic load, auth-required, config, and process-exit failures from the UI with the matching mock fixtures. +20. Retry after each visible failure with the mock `stream-rich` fixture. + +## Then + +- Native `Error` instances preserve object identity. +- String, nested-object, and SDK error objects become `Error` instances with readable messages. +- SDK `code` and `data` fields are preserved on the normalized error. +- Circular error objects do not crash normalization. +- Failed `createSession` releases reserved threads, unregisters permission routing, and resets browser loading state. +- `loadSessionOrNew` falls back only after the load failure is observed and binds the actual new raw session id. +- Failed `sendMessage` emits an error update, returns the thread to a non-working terminal state, and does not duplicate the user message on retry. +- Missing-session service operations fail before touching the ACP connection and include the raw requested session id in diagnostics. +- Disposing a pending load resolves the pending operation with a structured disposed/cancelled failure and does not leave `pendingSessionLoads` stuck. +- WebMCP service-unavailable responses use `{ success: false, error: "SERVICE_UNAVAILABLE" }`. +- Invalid input responses use `{ success: false, error: "INVALID_INPUT" }`. +- WebMCP `details` strings are bounded and redact token/key/secret/password patterns. +- Invalid fallback broker arguments return `INVALID_ARGUMENTS` and describe `{ tool: string, arguments?: object }`. +- Browser fallback renders a usable chat surface instead of an infinite loading state. +- Visible UI may show a concise user-facing error, but must not show uncaught stack traces, raw JSON-RPC payloads, MCP tokens, or full prompt/assistant content. +- A successful retry after either create or send failure clears stale loading/error state and produces a single active session/message stream. + +## Pass / Fail Judgment + +- **PASS** - ACP failures are normalized, redacted, and recoverable across node service, MCP tool, and browser UI boundaries. +- **FAIL** - failures leak secrets or content, leave thread/session loading state stuck, silently no-op missing-session operations, or prevent a later successful UI send. diff --git a/test/bdd/acp-layout-switch.scenario.md b/test/bdd/acp-layout-switch.scenario.md new file mode 100644 index 0000000000..4a8e4b337b --- /dev/null +++ b/test/bdd/acp-layout-switch.scenario.md @@ -0,0 +1,53 @@ +# Scenario: ACP Layout Switch - Agentic And Classic IDE Interop + +**Trigger:** `packages/ai-native/src/browser/layout/panel-layout.service.ts`, `packages/ai-native/src/browser/layout/ai-layout.tsx`, `packages/ai-native/src/browser/layout/tabbar.view.tsx`, or `packages/ai-native/src/browser/acp/webmcp-groups/acp-chat.webmcp-group.ts` + +**Layer:** `runtime-ui` **Required profile:** `default` **Fixtures:** IDE dev server opened on the default Playwright workspace with Common Preflight. **Workspace mutation:** None; this scenario is read-only. **Automation status:** Automated through Chrome DevTools MCP; WebMCP reads run only when exposed by the active profile. + +## Given + +- Common preflight in `test/bdd/README.md` passes through Chrome DevTools MCP. +- Browser `navigator.modelContext` is available, or the MCP `opensumi-ide` server is connected. +- The IDE is opened with a workspace that contains `editor.js` and `test/test.js`. +- The test is read-only. It must not create, modify, move, or delete files. + +## When + +1. `chrome-devtools-mcp`: Open `http://localhost:8080/?workspaceDir=`. +2. `chrome-devtools-mcp-wait`: Wait until the Common Preflight browser readiness predicate passes. +3. `webmcp`: Show the ACP chat view with `acp_chat_show_chat_view({})` when that tool is exposed. +4. `chrome-devtools-mcp`: Switch to `classic` with the user-facing menu path `View -> Panel Layout -> Classic`. +5. `chrome-devtools-mcp`: Assert the Explorer/workbench area is positioned before the AI chat slot, and the AI chat slot is visible. +6. `chrome-devtools-mcp`: Drag the Classic AI chat/workbench horizontal splitter in both directions and assert the AI chat width stays within its Classic resize bounds: minimum `280px`, maximum `1080px`. +7. `chrome-devtools-mcp`: Open Explorer, expand `test`, open `test/test.js`, and assert an editor tab is active. +8. `webmcp`: Read current IDE state through read-only tools: + - `workspace_get_info({})` + - `editor_get_active({})` + - `file_exists({ path: "editor.js" })` when exposed by the active profile + - `file_read({ path: "editor.js" })` when exposed by the active profile +9. `chrome-devtools-mcp`: Switch to `agentic` with the user-facing menu path `View -> Panel Layout -> Agent`. +10. `chrome-devtools-mcp`: Assert the AI chat slot is positioned before the Explorer/workbench area, and the Explorer remains visible. +11. `chrome-devtools-mcp`: Drag the Agentic AI chat/workbench horizontal splitter in both directions and assert the AI chat width stays within its Agentic resize bounds: minimum `640px`, maximum `1440px`. +12. Repeat steps 7 and 8 after the `agentic` switch. + +## Then + +- Both layout switches complete without reloading or navigating away from the workspace URL. +- The AI chat slot remains visible after both switches. +- The AI chat splitter enforces the layout-specific resize range: + - Classic: `280px <= AI Chat <= 1080px`. + - Agentic: `640px <= AI Chat <= 1440px`. +- Explorer remains visible and can expand folders and open files after both switches. +- WebMCP read-only calls return successful, workspace-scoped responses after both switches. +- Browser and MCP tool catalogs expose canonical underscore tool names only; legacy `_opensumi/...` names are absent. +- If `navigator.modelContext` and the MCP bridge are both unavailable, the failure output includes `navigator.modelContext missing` or `opensumi-ide MCP tools/list unavailable`. + +## Live Agent Execution + +- A real LLM-backed ACP agent is not required for this read-only layout scenario and does not replace stable layout selectors, splitter selectors, workspace fixtures, or read-only WebMCP exposure. +- If a live agent happens to populate AI Chat during the run, generated chat content is evidence only and must not affect the layout-switch pass/fail oracle. + +## Pass / Fail Judgment + +- **PASS** - layout switching works in both directions, file-tree interaction remains healthy, layout-specific AI chat resize bounds hold, and read-only WebMCP state checks succeed. +- **FAIL** - the layout order is wrong, the AI chat view disappears, Explorer cannot interact with the file tree after switching, a splitter lets AI chat escape its layout-specific resize bounds, WebMCP read-only tools fail when exposed, or legacy `_opensumi/...` tool names appear. diff --git a/test/bdd/acp-mcp-bridge.scenario.md b/test/bdd/acp-mcp-bridge.scenario.md new file mode 100644 index 0000000000..7eaaf731af --- /dev/null +++ b/test/bdd/acp-mcp-bridge.scenario.md @@ -0,0 +1,104 @@ +# Scenario: ACP Built-in MCP Bridge - Inject OpenSumi Capabilities Safely + +**Trigger:** `packages/ai-native/src/node/acp/opensumi-mcp-http-server.ts` or `packages/ai-native/src/node/acp/acp-agent.service.ts` + +**Layer:** `mcp-contract` **Required profile:** `default`, `interactive`, and `full` comparison runs. **Fixtures:** ACP agent with HTTP MCP support and fresh MCP transport sessions. **Workspace mutation:** None. **Automation status:** Automated MCP contract spec; no browser UI interaction is required. + +## Given + +- The agent is initialized and reports `mcpCapabilities.http === true`. +- `OpenSumiMcpHttpServer` can start on loopback. +- WebMCP group definitions are available from the browser caller service. +- The browser WebMCP surface exposes `opensumi_get_mcp_server_connection` for stable client discovery. +- The active MCP profile is recorded from the group registry metadata. + +## When + +### Part A - Server Startup And Injection + +1. `AcpAgentService.createSession(config)` is called. +2. `getSessionMcpServers` filters user configured MCP servers against agent MCP capabilities. +3. `OpenSumiMcpHttpServer.start()` is called if HTTP MCP is supported. +4. `newSession` receives configured MCP servers plus the built-in `opensumi-ide` HTTP MCP server. +5. Inspect node logs emitted during MCP server startup. + +### Part B - MCP Transport And Catalog + +6. Discover the bridge URL with `opensumi_get_mcp_server_connection`, then connect an MCP client to the returned Streamable HTTP URL. +7. Call `tools/list`. +8. Call `opensumi_discover_capabilities({ task, includeDisabled: true })`. +9. Call `opensumi_describe_capability_group({ group: "acp_chat", includeSchemas: true })`. +10. Call `opensumi_describe_tool({ tool: "acp_chat_get_session_state" })`. +11. Call `opensumi_describe_tool({ tool: "_opensumi/acp_chat/getSessionState" })`. +12. Call `opensumi_describe_tool({ tool: "acp_chat_getSessionState" })`. + +### Part C - Catalog Helper And Fallback Invocation + +13. In client A, call `opensumi_enable_capability_group({ group: "acp_chat" })` and record the helper result as catalog/discovery acknowledgement. +14. Refresh `tools/list` for client A. +15. Connect client B as a fresh MCP session, call `tools/list`, and record the same active-profile exposure without calling the helper. +16. In client A, call a profile-exposed ACP read tool directly or through `opensumi_invoke_capability_tool`. Use `acp_chat_get_session_state` in default profile and `acp_chat_list_sessions` in interactive/full profiles. +17. In client A, call the same profile-exposed ACP read tool through `opensumi_invoke_capability_tool` with the common accidental nested shape: + +```json +{ + "tool": "acp_chat_list_sessions", + "arguments": { + "arguments": {} + } +} +``` + +18. In client A, call the same profile-exposed ACP read tool through `opensumi_invoke_capability_tool` with the whole invocation nested under `arguments`: + +```json +{ + "arguments": { + "tool": "acp_chat_list_sessions", + "arguments": {} + } +} +``` + +19. In client A, call `opensumi_invoke_capability_tool` without a string `tool`. +20. In client B, call the same profile-exposed tool through `opensumi_invoke_capability_tool` without first calling `opensumi_enable_capability_group`. + +### Part D - Profile Exposure + +21. In default profile, inspect tools exposed before and after the optional catalog helper call. +22. In interactive profile, inspect tools exposed before and after the optional catalog helper call. +23. In full profile, inspect tools exposed before and after the optional catalog helper call. + +### Part E - Transport Lifecycle + +24. Issue a valid MCP request and record the returned `mcp-session-id`. +25. Send a follow-up request with the valid `mcp-session-id`. +26. Send a request with an unknown `mcp-session-id`. +27. Send `DELETE` with the valid `mcp-session-id`. +28. Send another request with the deleted `mcp-session-id`. + +## Then + +- The bridge listens only on `127.0.0.1` with an unguessable `/mcp/` path. +- User-visible node logs must not include the full bridge URL or token; startup logs may include the loopback host/port but must redact the path as `/mcp/`. +- Requests with the wrong path or non-loopback host are rejected. +- If the agent does not support HTTP MCP, the built-in server is not injected. +- If a configured MCP server already uses the built-in server name, the built-in server is not duplicated. +- `tools/list` includes canonical underscore tool names only. +- Catalog tools describe groups and tools without exposing file/chat contents. +- Legacy `_opensumi/...` and camelCase ACP Chat names return `TOOL_NOT_FOUND` or equivalent failure. +- `opensumi_enable_capability_group` may acknowledge the group for catalog/discovery purposes, but active-profile tool exposure does not depend on that helper. +- A fresh client B sees the same active-profile tool surface as client A for profile-granted tools, without inheriting any transport-local catalog helper state. +- In default profile, only default-safe read/ui tools remain exposed. +- In interactive profile, read tools such as `acp_chat_list_sessions`, `acp_chat_get_available_commands`, and `acp_chat_prepare_session_digest` are exposed, but full-profile debug/write tools remain hidden. +- In full profile, full-profile tools such as `acp_chat_read_session_messages`, `acp_chat_set_session_mode`, and `acp_chat_post_prepared_relay` are exposed. +- `opensumi_invoke_capability_tool` accepts the canonical fallback shape and the two common accidental nested shapes, normalizing all of them to the target tool's actual arguments before execution. +- `opensumi_invoke_capability_tool` without a valid string `tool` fails with `INVALID_ARGUMENTS` or equivalent structured failure and explains the expected `{ tool: string, arguments?: object }` shape. +- Calling a profile-forbidden tool is rejected or absent from `tools/list`, even if the catalog helper was called. +- Unknown or deleted `mcp-session-id` requests return 404 and do not create a new transport implicitly. +- `DELETE` releases the transport and removes any transport-local catalog helper state. + +## Pass / Fail Judgment + +- **PASS** - the bridge is loopback/token scoped, injects only when supported, redacts secrets in logs, exposes canonical tools, normalizes fallback broker arguments, and enforces profile-gated visibility. +- **FAIL** - bridge URLs or tokens leak in logs, legacy aliases work, nested fallback arguments are passed through incorrectly, profile-granted tools require a helper call, or write tools are exposed outside full profile. diff --git a/test/bdd/acp-permission-routing.scenario.md b/test/bdd/acp-permission-routing.scenario.md new file mode 100644 index 0000000000..951e023b89 --- /dev/null +++ b/test/bdd/acp-permission-routing.scenario.md @@ -0,0 +1,70 @@ +# Scenario: ACP Permission Routing - Registered Sessions and Dialog Lifecycle + +**Trigger:** `packages/ai-native/src/node/acp/permission-routing.service.ts`, `packages/ai-native/src/node/acp/acp-thread.ts`, or `packages/ai-native/src/browser/acp/permission-bridge.service.ts` + +**Layer:** `node-contract` **Required profile:** `full` when validating visible permission dialogs. **Fixtures:** Registered ACP sessions from the mock ACP agent `node test/bdd/fixtures/acp-agent/mock-acp-agent.mjs --fixture=permission`, permission bridge, and stable permission dialog selectors. **Workspace mutation:** None. **Automation status:** Automated contract spec for routing/service behavior; permission-visible full-profile portions are converted in `tools/playwright/src/tests/permission-dialog.test.ts` using the deterministic `permission` fixture and browser UI dismissal. + +## Given + +- A raw ACP session id exists from the mock `permission` fixture or an equivalent registered ACP session fixture. +- The session is registered through `PermissionRoutingService.registerSession`. +- The browser `AcpPermissionBridgeService` is available. +- The ACP chat view has an active session id. + +## When + +### Part A - Registered Session Route + +1. Agent calls ACP client `requestPermission` for the registered session. +2. Node routes the request through `PermissionRoutingService.routePermissionRequest`. +3. Browser calls `AcpPermissionBridgeService.showPermissionDialog`. +4. Chrome DevTools MCP observes the visible permission dialog. +5. MCP calls `acp_chat_get_permission_state`. +6. User selects an allow option. +7. Repeat with permission options in an unsorted order. + +### Part B - Reject And Close + +8. Trigger another permission request for the same active session. +9. User selects a reject option. +10. Trigger another permission request and close the dialog. +11. Trigger a duplicate `requestId` while the first request is still pending. + +### Part C - Unregistered Session + +12. Unregister the raw session id. +13. Route a new permission request for that session id. + +### Part D - Session Cleanup + +14. Trigger permissions for two different sessions. +15. Make one session active. +16. Call `clearSessionDialogs` for the active session. +17. Call `cancelRequest(requestId)` for a pending request in another session. + +### Part E - Skip Permission Mode + +18. Set `SKIP_PERMISSION_CHECK=true`. +19. Route a permission request with `allow_once`, `allow_always`, `reject_once`, and `reject_always` options. + +## Then + +- Part A returns an ACP allow outcome to the agent only after the user decision. +- Permission options render in the stable order `allow_always`, `allow_once`, `reject_always`, `reject_once` regardless of input order. +- `activeDialogCount` increases while the dialog is visible. +- `activeSessionId` reports the raw active ACP session id. +- `pendingCountExcludingActive` excludes the active session and counts other sessions only. +- `hasPendingForSession` accepts both `acp:` and raw ``. +- Reject returns a reject outcome and removes the dialog from pending indexes. +- Close returns `timeout` or cancelled-equivalent outcome and removes pending indexes. +- A duplicate pending `requestId` returns cancelled and does not replace the existing dialog resolver. +- Unregistered sessions return cancelled without showing a browser dialog. +- `clearSessionDialogs(sessionId)` resolves matching pending decisions as cancelled and leaves other sessions' dialogs untouched. +- `cancelRequest(requestId)` closes only the matching request and leaves other pending requests untouched. +- With `SKIP_PERMISSION_CHECK=true`, no browser dialog is shown and the first allow option is selected deterministically. +- Permission observability never exposes full permission content, file contents, or an automated approve/reject MCP tool. + +## Pass / Fail Judgment + +- **PASS** - permission requests are routed only for registered sessions, browser dialogs are observable, and all decisions clean up per-session pending indexes. +- **FAIL** - unregistered sessions show dialogs, counts become stale, decisions cross sessions, or MCP exposes permission content/decision tools. diff --git a/test/bdd/acp-process-config.scenario.md b/test/bdd/acp-process-config.scenario.md new file mode 100644 index 0000000000..d092f56fd7 --- /dev/null +++ b/test/bdd/acp-process-config.scenario.md @@ -0,0 +1,53 @@ +# Scenario: ACP Process Config - Browser Merge and Node Spawn Resolution + +**Trigger:** `packages/ai-native/src/browser/acp/build-agent-process-config.ts` or `packages/ai-native/src/node/acp/acp-spawn-config.ts` + +**Layer:** `node-contract` **Required profile:** `default` **Fixtures:** Browser preference fixture and node spawn config resolver fixture using `node test/bdd/fixtures/acp-agent/mock-acp-agent.mjs --fixture=stream-rich` as the concrete ACP agent command. **Workspace mutation:** None. **Automation status:** Automated contract spec; no runtime IDE interaction is required. + +## Given + +- An ACP agent registration exists with `agentId`, `command`, `args`, `env`, and `cwd`; the default command points to `node` with args including `test/bdd/fixtures/acp-agent/mock-acp-agent.mjs` and `--fixture=stream-rich`. +- User preferences may override agent `command`, `args`, `env`, and `nodePath`. +- Node process environment may include `SUMI_ACP_NODE_PATH` and `SUMI_ACP_AGENT_PATH`. + +## When + +### Part A - Browser Config Merge + +1. Call `buildAcpAgentProcessConfig` with registration defaults only. +2. Call it with user overrides for command and args. +3. Call it with registration env and user env overrides using the same key. +4. Call it with configured MCP servers. +5. Call it with empty-string user overrides. +6. Mutate the returned env/args arrays and inspect the original registration object. + +### Part B - Node Spawn Resolution + +7. Call `resolveAgentSpawnConfig` without ACP environment overrides. +8. Call it with `SUMI_ACP_NODE_PATH`. +9. Call it with `SUMI_ACP_AGENT_PATH`. +10. Call it with a relative node path. +11. Call it with an agent command containing spaces. +12. Call it when `PATH` is empty or missing from the node environment. + +## Then + +- Registration defaults are preserved when no user override exists. +- User command and args override registration command and args. +- Empty-string user overrides are ignored unless the setting explicitly allows blank values. +- Environment variables merge by name; user env values win on duplicate names. +- Building config does not mutate registration defaults or user preference arrays. +- `cwd` always comes from the registration workspace value. +- MCP servers are carried through only when provided. +- Node resolution chooses node path in this order: `SUMI_ACP_NODE_PATH` -> user preference `nodePath` -> `process.execPath`. +- The resolved env sets `NODE` to the selected node executable directory plus `/node`. +- The resolved env prepends the selected node executable directory to `PATH`. +- `SUMI_ACP_AGENT_PATH` overrides the browser-resolved command. +- A relative node path fails fast with a clear absolute-path error. +- Commands containing spaces are passed as the executable path plus args according to existing spawn conventions; they are not shell-split implicitly. +- Missing `PATH` is handled by creating a deterministic path that includes the selected node executable directory. + +## Pass / Fail Judgment + +- **PASS** - browser config merge and node spawn resolution are deterministic and do not silently accept unsafe relative node paths. +- **FAIL** - env overrides are lost, command/node override precedence is wrong, or relative node paths reach process spawning. diff --git a/test/bdd/acp-rpc-bridge-and-status.scenario.md b/test/bdd/acp-rpc-bridge-and-status.scenario.md new file mode 100644 index 0000000000..ee0f95c03e --- /dev/null +++ b/test/bdd/acp-rpc-bridge-and-status.scenario.md @@ -0,0 +1,60 @@ +# Scenario: ACP RPC Bridge and Thread Status - Browser/Node Synchronization + +**Trigger:** `packages/ai-native/src/browser/acp/acp-webmcp-rpc.service.ts`, `packages/ai-native/src/node/acp/acp-webmcp-caller.service.ts`, `packages/ai-native/src/browser/acp/acp-thread-status-rpc.service.ts`, or `packages/ai-native/src/node/acp/acp-thread-status-caller.service.ts` + +**Layer:** `node-contract` **Required profile:** `default` **Fixtures:** Browser WebMCP registry, node RPC caller services, and controllable RPC client spies. **Workspace mutation:** None. **Automation status:** Automated contract spec for browser/node RPC synchronization. + +## Given + +- Common preflight in `test/bdd/README.md` passes when this scenario is run through the IDE. +- The browser injector has registered the WebMCP group registry and ACP Chat group. +- The Node side has an `AcpWebMcpCallerService` and `AcpThreadStatusCallerService`. +- The browser chat manager has at least one ACP chat model whose browser id is `acp:`. +- The test harness can replace or spy on the RPC client used by the node caller services. + +## When + +### Part A - WebMCP Group Definition RPC + +1. Set the node caller's RPC client to the browser `AcpWebMcpRpcService`. +2. Call `AcpWebMcpCallerService.getGroupDefinitions({ includeAllTools: true })`. +3. Call `AcpWebMcpCallerService.getGroupDefinitions({ includeAllTools: false })`. +4. Inspect the returned ACP Chat group definition and its tool names. + +### Part B - WebMCP Tool Execution RPC + +5. Execute `acp_chat_get_session_state` through `AcpWebMcpCallerService.executeTool("acp_chat", "acp_chat_get_session_state", {})`. +6. Execute a missing tool name through the same RPC path. +7. Execute a valid tool while the browser-side service dependency is unavailable. + +### Part C - Missing RPC Client + +8. Clear both the instance RPC client and static RPC client. +9. Call `getGroupDefinitions` and `executeTool`. + +### Part D - Thread Status Push + +10. Restore the status RPC client. +11. Call `AcpThreadStatusCallerService.notifyThreadStatusChange(rawSessionId, "working")`. +12. Call `notifyThreadStatusChange("acp:" + rawSessionId, "awaiting_prompt")`. +13. Call `notifyThreadStatusChange` for an unknown raw session id. +14. Clear the status RPC client and call `notifyThreadStatusChange(rawSessionId, "disconnected")`. + +## Then + +- Part A returns group definitions from the browser registry without constructing a separate node-side catalog. +- Returned tool names use the lower-snake canonical `tool.name` values from the registry. +- `includeAllTools: true` includes profile-gated ACP Chat tools such as `acp_chat_set_session_mode`; `includeAllTools: false` returns only currently exposed tools. +- Part B returns the same success/failure class and payload shape as a direct browser registry execution. +- Missing tool execution fails with a structured not-found or invalid-tool result; it must not throw an unstructured RPC exception to the MCP transport. +- Service-unavailable executions return `{ success: false, error: "SERVICE_UNAVAILABLE" }` with a bounded `details` string. +- Part C fails fast with an error that identifies the missing browser RPC connection; it must not hang or retry indefinitely. +- Part D updates the browser chat model when the session id is passed either raw or prefixed with `acp:`. +- Unknown-session status notifications are ignored without creating a new chat model. +- Missing status RPC clients are ignored silently so node-side ACP streaming does not fail just because the browser is not ready. +- A later valid status notification still updates the existing model after the RPC client is restored. + +## Pass / Fail Judgment + +- **PASS** - WebMCP definitions/execution and thread-status updates cross the browser/node RPC boundary with canonical names, structured failures, raw/prefixed session id normalization, and no hangs when RPC is missing. +- **FAIL** - node builds a divergent catalog, tool names drift from the browser registry, missing RPC causes a stuck MCP call, status updates miss valid ACP sessions, or unknown status updates create phantom sessions. diff --git a/test/bdd/acp-session-advanced-operations.scenario.md b/test/bdd/acp-session-advanced-operations.scenario.md new file mode 100644 index 0000000000..5f14c12498 --- /dev/null +++ b/test/bdd/acp-session-advanced-operations.scenario.md @@ -0,0 +1,70 @@ +# Scenario: ACP Session Advanced Operations - Config, Fork, Resume, Close, Model + +**Trigger:** `packages/ai-native/src/node/acp/acp-agent.service.ts` or `packages/ai-native/src/node/acp/acp-thread.ts` + +**Layer:** `node-contract` **Required profile:** `default` **Fixtures:** The mock ACP agent at `node test/bdd/fixtures/acp-agent/mock-acp-agent.mjs --fixture=stream-rich` exposes config, fork, resume, close, model, and mode operations; `--fixture=config-failure` covers deterministic config failures. **Workspace mutation:** None. **Automation status:** Automated contract spec; runtime mode visibility is covered by `session-mode.scenario.md`. + +## Given + +- An ACP session has been created and is registered in `AcpAgentService` using the raw ACP `sessionId`. +- The backing `AcpThread` is initialized and connected. +- The test agent implements the ACP session extension methods: + - `setSessionConfigOption` + - `unstable_forkSession` + - `unstable_resumeSession` + - `unstable_closeSession` + - `unstable_setSessionModel` +- The test harness can observe calls made through the ACP SDK connection or the ACP debug log produced by the mock process. + +## When + +### Part A - Session Config Options + +1. Call `setSessionConfigOption({ sessionId, configId, value: true })`. +2. Call `setSessionConfigOption({ sessionId, configId, value: "custom" })`. +3. Call `setSessionConfigOption` for a missing session id. +4. Call `setSessionConfigOption` with an empty `configId`. + +### Part B - Fork + +5. Call `forkSession({ sessionId, cwd, mcpServers })`. +6. Call `forkSession` for a missing session id. +7. Call `forkSession` with a forked agent response that omits the new session id. + +### Part C - Resume And Close + +8. Call `resumeSession({ sessionId, cwd })`. +9. Call `closeSession({ sessionId })`. +10. Call `resumeSession` and `closeSession` for missing session ids. + +### Part D - Model Selection + +11. Call `setSessionModel({ sessionId, model })`. +12. Call `setSessionModel` for a missing session id. +13. Call `setSessionModel` with an empty model id. + +### Part E - Available Modes + +14. Initialize an agent with `modes.availableModes`. +15. Call `getAvailableModes()`. + +## Then + +- Boolean config values are sent to ACP with `type: "boolean"` and the boolean value preserved. +- String config values are sent without incorrectly adding `type: "boolean"`. +- Missing-session config changes fail with a clear `No active session` error and do not call the ACP connection. +- Empty `configId` or model id fails with a structured validation error before calling the ACP connection. +- `forkSession` forwards the raw source session id, optional `cwd`, and optional `mcpServers`, then returns the raw forked session id from the agent. +- A fork response without a session id fails clearly and does not bind a phantom session. +- Missing-session fork calls fail before touching the ACP connection. +- `resumeSession` forwards the raw session id and uses the supplied `cwd`, or the thread cwd when none is provided. +- `closeSession` forwards the raw session id and does not unregister or dispose the OpenSumi session mapping by itself. +- Missing-session resume, close, and model-selection calls fail before touching the ACP connection. +- `setSessionModel` forwards the raw session id and requested model string. +- `getAvailableModes()` returns the initialized mode metadata when the agent reports it; if no modes are reported, it returns `null` or an empty value consistently. +- All failures preserve the raw ACP session id in diagnostics and never convert it to an `acp:` browser id. + +## Pass / Fail Judgment + +- **PASS** - all advanced session operations delegate to the ACP connection with raw session ids, correct request shape, and clear missing-session failures. +- **FAIL** - boolean config shape is wrong, browser-prefixed session ids reach node ACP calls, fork/model/close/resume calls silently no-op, or available modes cannot be observed after initialization. diff --git a/test/bdd/acp-thread-pool-lru.scenario.md b/test/bdd/acp-thread-pool-lru.scenario.md new file mode 100644 index 0000000000..36a1cebd89 --- /dev/null +++ b/test/bdd/acp-thread-pool-lru.scenario.md @@ -0,0 +1,69 @@ +# Scenario: ACP Thread Pool LRU - Recycle, Reload, And Failure Recovery + +**Trigger:** `packages/ai-native/src/node/acp/acp-agent.service.ts` or `packages/ai-native/src/browser/chat/chat.internal.service.acp.ts` + +**Layer:** `node-contract` **Required profile:** `default` **Fixtures:** Deterministic ACP thread pool with controllable statuses, reservations, and pending loads. Process-backed subcases may use the mock ACP agent `--fixture=history` for reloadable sessions, `--fixture=long-stream` for `working` threads, and `--fixture=auth-required` for auth-required status/error recovery, but reservation and pending-load races still require a dedicated service harness. **Workspace mutation:** None. **Automation status:** Automated contract spec; visible loading-state checks may also run through the IDE. + +## Given + +- Common preflight in `test/bdd/README.md` passes if this is run through the IDE. +- ACP agent mode is enabled, with the mock ACP agent configured for process-backed session/status subcases when the scenario is run through the real ACP process path. +- The ACP thread pool limit is 3. +- ACP sessions use raw node-side ACP session ids; browser session ids may use the `acp:` prefix only in browser models. +- A reusable thread is one whose status is `idle` or `awaiting_prompt`, is not reserved by an in-flight `createSession`, and is not part of `pendingSessionLoads`. +- A non-reusable thread is any thread in `working`, `auth_required`, reserved, or pending-load state. + +## When + +### Part A - Create A Fourth Session + +1. Create 3 ACP sessions so the pool is full. +2. Ensure all 3 bound threads are in `awaiting_prompt`. +3. Make `session-1` the least recently used session. +4. Click New Session or call `createSession`. + +### Part B - Send To An Evicted Session + +5. Let `session-1` be evicted by LRU and no longer present in the node active session map. +6. Keep the browser chat model for `session-1`. +7. Send a message from the `session-1` chat input. + +### Part C - Concurrent Create And Load + +8. Start `createSession` and pause it after a thread is created but before the real ACP session id is known. +9. Start `loadSession` for another historical session while the create thread is reserved. +10. Allow both operations to complete. + +### Part D - Pool Full Without Reusable Threads + +11. Fill the pool with 3 active sessions. +12. Put all bound threads into non-reusable states such as `working`, `auth_required`, reserved, or pending load. +13. Click New Session. + +### Part E - Existing Idle Thread + +14. Dispose or unbind a session so the pool contains an unbound idle thread. +15. Create or load another ACP session. + +## Then + +- Part A reuses the least recently used reusable thread instead of creating a fourth thread. +- Part A keeps the pool size at 3. +- Part A logs `thread-pool-switch` with `reason=create-session`, `evictSessionId`, `nextSessionId`, `threadId`, `status`, and `pool=3/3`. +- Part B automatically calls `loadSession(session-1)` before sending the message. +- Part B does not show `No active session for sessionId: session-1`. +- Part B sends the user prompt after `session-1` is reloaded. +- Part C does not let `loadSession` reuse the reserved create thread. +- Part C leaves the completed created session bound to the originally reserved thread. +- Part D does not recycle `working` or `auth_required` threads. +- Part D fails with `Thread pool is full (3), no reusable LRU thread available`. +- Part D logs `thread-pool-switch-failed` with a `candidates` array. +- Each failure candidate includes `threadId`, `sessionId`, `status`, `reserved`, `pendingLoad`, and `reusable`. +- Part D resets browser session loading state to false and shows a create session failure message instead of leaving the page stuck in loading. +- Part E directly reuses the unbound idle thread. +- Part E does not emit `thread-pool-switch` because no active session is evicted. + +## Pass / Fail Judgment + +- **PASS** - ACP can open or switch more than 3 sessions by LRU-recycling only safe threads, evicted sessions can be lazily reloaded before prompts, create and load races do not steal reserved threads, and failure leaves the UI usable with diagnostic logs. +- **FAIL** - a fourth session creates a fourth thread, an active working or permission-waiting session is evicted, an evicted session cannot send a message after reload, load steals an in-flight create thread, or New Session failure leaves the browser stuck in loading. diff --git a/test/bdd/available-commands.scenario.md b/test/bdd/available-commands.scenario.md new file mode 100644 index 0000000000..a98b4bb809 --- /dev/null +++ b/test/bdd/available-commands.scenario.md @@ -0,0 +1,49 @@ +# Scenario: Available Commands - Profile-Granted ACP Chat Exposes Command Metadata + +**Trigger:** `packages/ai-native/src/browser/acp/webmcp-groups/acp-chat.webmcp-group.ts` + +**Layer:** `mcp-contract` **Required profile:** `interactive` or `full` **Fixtures:** Fresh MCP session in a profile that exposes `acp_chat_get_available_commands` and command metadata from the mock ACP agent configured as `node test/bdd/fixtures/acp-agent/mock-acp-agent.mjs --fixture=stream-rich`; the active real ACP agent may supply live evidence only when its command catalog is stable for the run. **Workspace mutation:** None. **Automation status:** Automated MCP contract spec; default-profile runs should skip this scenario instead of marking it partial. Playwright conversion requires the stable mock command catalog or an equivalent deterministic provider. + +## Given + +- Common preflight in `test/bdd/README.md` passes. +- The MCP `opensumi-ide` server is connected. +- The IDE is running with `ai.native.webmcp.profile = "interactive"` or `"full"`. +- Default ACP Chat smoke in `acp-chat.scenario.md` passes. + +## When + +1. `mcp`: `tools/list` -> record `TOOLS_PROFILE`. +2. If `tools/list` contains `acp_chat_get_available_commands`, call it directly. +3. If the client cannot call the tool directly, call: + ```js + opensumi_invoke_capability_tool({ + tool: 'acp_chat_get_available_commands', + arguments: {}, + }); + ``` +4. Optionally call `opensumi_enable_capability_group({ group: "acp_chat" })` as a catalog helper and verify it is not required for the command metadata call. +5. Record the command metadata result as `COMMANDS_RESULT`. + +## Then + +- Step 1 includes `acp_chat_get_available_commands` in interactive/full profiles. +- Step 2 or Step 3 makes `acp_chat_get_available_commands` callable in this MCP session. +- Step 4, when run, returns `success: true`, `enabled: true`, and `group: "acp_chat"`, but does not change the profile boundary. +- Step 5 returns `success: true`. +- `COMMANDS_RESULT.result.commands` is an array. +- Every command item has a non-empty string `name`. +- Every command item has a string `description`; empty descriptions are allowed. +- Command names are not required to start with `/`. +- The response must not include chat message content, prompts, assistant responses, or tool-call results. + +## Live Agent Execution + +- A real LLM-backed ACP agent may provide command metadata for live MCP contract evidence when the interactive/full profile exposes `acp_chat_get_available_commands`. +- Live-agent mode must not assert exact command counts, ordering, generated command effects, or assistant content unless the command catalog is stable for the configured provider. CI hardening requires deterministic command metadata. + +## Pass / Fail Judgment + +- **PASS** - command metadata is callable and structurally valid in interactive/full profiles without requiring a catalog helper call. +- **BLOCKED** - the scenario is scheduled against default profile instead of interactive/full profile. +- **FAIL** - the tool cannot be invoked through direct or fallback path in an interactive/full profile, the catalog helper is incorrectly required, or command items are malformed. diff --git a/test/bdd/bdd-runtime-preflight.scenario.md b/test/bdd/bdd-runtime-preflight.scenario.md new file mode 100644 index 0000000000..671f5e6ab1 --- /dev/null +++ b/test/bdd/bdd-runtime-preflight.scenario.md @@ -0,0 +1,102 @@ +# Scenario: BDD Runtime Preflight - Browser Readiness and Execution Surface + +**Trigger:** `packages/ai-native/src/browser/acp/webmcp-model-context-adapter.ts`, `packages/ai-native/src/node/acp/opensumi-mcp-http-server.ts`, or `test/bdd/README.md` + +**Layer:** `runtime-ui` **Required profile:** `default` **Fixtures:** IDE dev server and, when ACP bridge checks run, an agent session with HTTP MCP support. **Workspace mutation:** None. **Automation status:** Automated preflight; downstream runtime scenarios are blocked until browser readiness passes. Scenarios that explicitly require the `opensumi-ide` MCP bridge are blocked only when that bridge surface is unavailable. + +## Given + +- The IDE dev server is running. +- A workspace path is available. +- Chrome DevTools MCP can connect to a browser target. +- An MCP client can connect to the built-in `opensumi-ide` MCP server when the server is injected into an ACP session. + +## When + +### Part A - Browser Readiness + +1. Open: + ```text + http://localhost:8080/?workspaceDir= + ``` +2. Wait until the IDE shell is ready and at least one stable workbench signal is visible: + ```js + const text = document.body.innerText || ''; + const shellReady = + document.readyState === 'complete' && + !!document.querySelector('#main') && + !document.querySelector('.loading_indicator'); + const workbenchVisible = + text.includes('EXPLORER') || + text.includes('Agentic') || + text.includes('editor.js') || + !!document.querySelector('.monaco-editor'); + shellReady && workbenchVisible; + ``` +3. Record visible fatal error text, modal startup prompts, and browser console errors. + +### Part B - Browser Tool Surface + +4. Evaluate: + ```js + Boolean(navigator.modelContext); + ``` +5. If present, evaluate: + ```js + navigator.modelContext + .getTools() + .map((tool) => tool.name) + .sort(); + ``` +6. If absent, record whether a test-only fallback surface such as `navigator.modelContextTesting` exists. +7. If `navigator.modelContext.executeTool` exists, call the default-safe ACP Chat tools: + ```js + navigator.modelContext.executeTool('acp_chat_get_session_state', {}); + navigator.modelContext.executeTool('acp_chat_get_permission_state', {}); + navigator.modelContext.executeTool('acp_chat_show_chat_view', {}); + ``` + +### Part C - MCP Bridge Surface + +8. If a downstream scenario requires MCP transport, call: + ```js + navigator.modelContext.executeTool('opensumi_get_mcp_server_connection', {}); + ``` + Use the returned `url` only for the MCP client and `redactedUrl` in evidence/logs. If the discovery tool is unavailable, create or load an ACP session with HTTP MCP supported and use the injected `opensumi-ide` server. +9. Connect an MCP client to the `opensumi-ide` Streamable HTTP server. +10. Call `tools/list`. +11. Call `opensumi_discover_capabilities({ task: "preflight", includeDisabled: true })`. +12. Enable `acp_chat` and call `acp_chat_get_session_state({})` directly or through `opensumi_invoke_capability_tool`. +13. If MCP transport is unavailable but browser `navigator.modelContext` can list and execute the required default tools, continue browser-only runtime scenarios and mark only MCP-dependent scenarios **BLOCKED**. + +### Part D - Failure Diagnostics + +14. If any preflight step fails, collect: + - IDE URL + - Chrome DevTools MCP target URL + - document readiness result + - whether `#main` exists + - whether `navigator.modelContext` exists + - browser `navigator.modelContext` tool names and default-safe call results, if available + - MCP `tools/list` names, if available and required + - relevant console errors without secrets + +## Then + +- Browser readiness must pass before any BDD scenario runs browser or DOM assertions. +- A BDD runner must have at least one supported execution surface: + - browser `navigator.modelContext`, or + - connected MCP `opensumi-ide` server with catalog tools. +- Browser and MCP surfaces expose canonical underscore tool names only when those surfaces are available. +- The literal `EXPLORER` text is a useful Explorer-specific signal, but it is not the only valid readiness marker for Agentic-first layouts. +- Extension host or worker-host console errors are recorded as diagnostics. They fail preflight only when they prevent shell readiness, block the scenario's required UI surface, or leak secrets. +- Runtime diagnostics must redact MCP token paths and secret-like query values. +- If no supported execution surface is available, downstream scenarios are marked **BLOCKED** instead of failed. +- If browser readiness and browser `navigator.modelContext` pass but MCP transport is unavailable, browser-only runtime scenarios may continue and MCP-dependent scenarios are marked **BLOCKED** with the missing MCP prerequisite. +- Blocked output points to the missing surface explicitly, for example `navigator.modelContext missing` or `opensumi-ide MCP tools/list unavailable`. + +## Pass / Fail Judgment + +- **PASS** - the IDE shell is ready, at least one stable workbench signal is visible, and at least one supported tool execution surface can list and invoke required canonical tools for the scheduled downstream scenario set. +- **BLOCKED** - the IDE renders but the execution surface required by the scheduled downstream scenario set is unavailable, for example MCP transport for MCP-dependent scenarios or browser `navigator.modelContext` for browser-only WebMCP checks. +- **FAIL** - the IDE does not render, browser readiness never completes, a fatal startup prompt blocks the required UI surface, required default-safe browser tool execution fails when browser runtime scenarios are scheduled, or diagnostics leak the full MCP bridge token or other secrets. diff --git a/test/bdd/error-handling.scenario.md b/test/bdd/error-handling.scenario.md new file mode 100644 index 0000000000..508b98db46 --- /dev/null +++ b/test/bdd/error-handling.scenario.md @@ -0,0 +1,77 @@ +# Scenario: ACP Chat Capability Boundaries and Invalid Inputs + +**Trigger:** `packages/ai-native/src/browser/acp/webmcp-groups/acp-chat.webmcp-group.ts` or `packages/ai-native/src/node/acp/opensumi-mcp-http-server.ts` + +**Layer:** `mcp-contract` **Required profile:** `full` for complete invalid-input coverage. **Fixtures:** Fresh MCP session and ACP Chat smoke state from `acp-chat.scenario.md`. **Workspace mutation:** None. **Automation status:** Automated MCP contract spec; default-profile boundary checks are covered by `acp-chat.scenario.md`. + +## Given + +- Common preflight in `test/bdd/README.md` passes. +- The MCP `opensumi-ide` server is connected. +- Use a fresh MCP client session for this scenario so transport-local catalog helper state does not leak in from another scenario. +- Default ACP Chat smoke in `acp-chat.scenario.md` passes. + +## When + +### Part A - Legacy Tool Boundary + +1. `mcp`: `tools/list` -> record `TOOLS_DEFAULT`. +2. Assert `TOOLS_DEFAULT` does not include: + - `acp_sendMessage` + - `acp_createSession` + - `acp_switchSession` + - `acp_clearSession` + - `acp_cancelRequest` + - `acp_handlePermissionDialog` +3. `mcp`: call a legacy tool name such as `acp_sendMessage({ message: "hello" })`. + +### Part B - Catalog Boundary + +4. `mcp`: `opensumi_describe_capability_group({ group: "acp_chat", includeSchemas: true })`. +5. In full profile, before any optional catalog helper call, verify `acp_chat_set_session_mode`, `acp_chat_post_prepared_relay`, and `acp_chat_read_session_messages` are exposed or callable through `opensumi_invoke_capability_tool`. +6. In a separate default/interactive boundary run, verify `acp_chat_set_session_mode`, `acp_chat_post_prepared_relay`, and `acp_chat_read_session_messages` are still not exposed or not callable. +7. If `opensumi_enable_capability_group({ group: "acp_chat" })` is called, treat it as a catalog/discovery helper and verify it does not change profile-forbidden exposure. + +### Part C - Invalid Inputs + +8. In full profile, call: + ```js + acp_chat_set_session_mode({ modeId: '' }); + ``` +9. In full profile, call: + +```js +acp_chat_prepare_session_digest({ sourceSessionId: '' }); +``` + +10. In full profile, call: + +```js +acp_chat_post_prepared_relay({ digestId: '', targetSessionId: '' }); +``` + +11. In full profile, call: + +```js +acp_chat_read_session_messages({ sessionId: '' }); +``` + +## Then + +- Step 2 passes: legacy direct ACP tools are absent from the MCP tool surface. +- Step 3 fails with a standard tool-not-found style MCP error. +- Step 4 returns `success: true`, `group: "acp_chat"`, and current tool schemas. +- Step 5 confirms full-profile tools are available without requiring `opensumi_enable_capability_group`. +- Step 6 confirms non-full profiles do not expose write tools or the full-profile debug read tool. This boundary run is a prerequisite evidence item for the complete full-profile pass. +- Step 7 confirms the catalog helper does not override profile gating. +- Step 8 returns `success: false` with `error: "INVALID_INPUT"`. +- Step 9 returns `success: false` with `error: "INVALID_INPUT"`. +- Step 10 returns `success: false` with `error: "INVALID_INPUT"`. +- Step 11 returns `success: false` with `error: "INVALID_INPUT"`. +- Error responses must not include chat prompts, assistant responses, permission content, or relay digest body. + +## Pass / Fail Judgment + +- **PASS** - old direct tools are blocked and invalid inputs fail with structured, non-leaking errors. +- **BLOCKED** - the scenario is scheduled without the required full profile, so full-profile invalid-input tools cannot be exercised. +- **FAIL** - a legacy tool is exposed, a profile-forbidden capability is callable, a profile-granted tool incorrectly requires the catalog helper, or invalid input succeeds. diff --git a/test/bdd/fixtures/acp-agent/CONTRACT.md b/test/bdd/fixtures/acp-agent/CONTRACT.md new file mode 100644 index 0000000000..c1de572f9f --- /dev/null +++ b/test/bdd/fixtures/acp-agent/CONTRACT.md @@ -0,0 +1,63 @@ +# ACP BDD Mock Agent Fixture Contract + +This contract summarizes the deterministic fixture modes consumed by BDD hardening work. It is based on the `test/bdd/evidence/2026-06-11` blocked reports and the fixture implementation in `mock-acp-agent.mjs`. + +## Determinism Rules + +- Fixture content is bounded sentinel data only. +- Do not emit raw prompts, assistant free text, secrets, credentials, or unbounded tool output. +- When a scenario needs more than one fixture class, run separate deterministic passes and record the fixture used by each pass. +- Scenario-specific selectors, product controls, browser profile setup, and live-agent prompt behavior are owned by the scenario owner, not by the shared mock agent. + +## Fixture Modes + +| Fixture | Supported behavior | +| --- | --- | +| `stream-rich` | Bounded user row, thought chunks, plan entries, assistant chunks, tool-call lifecycle, config snapshot, usage, modes, models, config options, and available commands. | +| `long-stream` | Bounded repeated assistant chunks with cancellable pending prompt state and deterministic cancel sentinel. | +| `permission` | Bounded pending tool call plus ACP permission request with allow/reject outcomes reflected as tool-call update and assistant sentinel. | +| `send-failure` | Deterministic `session/prompt` failure. | +| `create-failure` | Deterministic `session/new` failure. | +| `load-failure` | Deterministic `session/load` not-found failure. | +| `auth-required` | Deterministic ACP auth-required prompt failure. | +| `config-failure` | Deterministic `session/set_config_option` failure. | +| `process-exit` | Emits deterministic prompt updates, then exits the ACP agent process with a fixed non-zero code. | +| `history` | Two deterministic seeded sessions, stable list ordering, normal modes/models/config/options, and bounded rich replay on `session/load` using user, thought, plan, assistant, tool-call, tool-result, and usage updates. | + +## Capability Matrix + +| Scenario | Required fixture mode(s) | Currently supported behavior | Missing behavior / owner request | +| --- | --- | --- | --- | +| `acp-chat-agentic-fallback` | none | Not an ACP mock-agent contract. Backend-readiness failure is covered by the local-loopback `acpBddBackendReadyFailure=reject` runtime fixture and Playwright coverage in `tools/playwright/src/tests/acp-chat-agentic-fallback.test.ts`. | No shared mock-agent fixture gap. | +| `acp-layout-switch` | none | Not an ACP mock-agent contract. | Scenario owner needs stable user-facing Agentic/Classic layout switch control or a runtime-supported Classic override. | +| `acp-chat-agentic-input-send` | `stream-rich`, `create-failure`, `send-failure` | All named fixture modes exist and are bounded. Recovery subcases are covered in `tools/playwright/src/tests/acp-chat-agentic-error-taxonomy.test.ts`. | Broader input, command, mention, attachment, and scroll subcases remain scenario-owned. | +| `acp-chat-agentic-stream-rendering` | `stream-rich`, `send-failure` | Rich stream and send-failure recovery fixtures exist. | Scenario owner needs scheduled full matrix and stable render selectors. | +| `acp-chat-agentic-cancel-stop` | `long-stream`, `stream-rich` | Long active stream, cancellation sentinel, and follow-up success fixture exist. | Scenario owner needs stable visible stop/cancel selector and scheduled pass. | +| `acp-chat-agentic-rich-history-restore` | `history` | `history` now seeds two sessions and replays bounded rich updates on load. Hardened Playwright coverage exists in `tools/playwright/src/tests/acp-chat-agentic-rich-history-restore.test.ts`. | Reload coverage currently asserts bounded shell recovery because product reload restores transcript rows, not full non-message replay parts. | +| `acp-chat-agentic-permission-during-send` | `permission`, `stream-rich` | Permission request fixture, stable dialog close/reject selectors, active badge/count observability, and full-profile Playwright coverage exist in `tools/playwright/src/tests/permission-dialog.test.ts`. | Same-session non-permission follow-up still uses a separate `stream-rich` pass unless the fixture grows per-prompt branching. | +| `acp-chat-agentic-session-isolation` | `history`, `long-stream`, `stream-rich` | Seeded history, controlled active stream, and completed stream fixtures exist. Hardened history-backed isolation coverage exists in `tools/playwright/src/tests/acp-chat-agentic-session-isolation.test.ts`. | Concurrent long-stream isolation still needs orchestration that preserves an active stream while switching sessions. | +| `acp-chat-agentic-context-attachments` | `stream-rich` | Normal deterministic send shell exists without prompt leakage. | Scenario owner needs stable context picker/attachment selectors and optional rule fixture. | +| `acp-chat-agentic-command-surface` | `stream-rich` | Available command metadata and rich send fixture exist. | Scenario owner needs stable slash picker selection/cancel/send selectors. | +| `acp-chat-agentic-reload-during-stream` | `long-stream`, `stream-rich` | Reloadable active stream and post-reload success fixtures exist. | Scenario owner needs scheduled reload-during-stream pass and stable recovery assertions. | +| `acp-chat-agentic-error-taxonomy` | `create-failure`, `load-failure`, `send-failure`, `auth-required`, `config-failure`, `process-exit`, `stream-rich` | Named failure, retry, process-exit, and recovery fixtures exist. Hardened Playwright coverage exists in `tools/playwright/src/tests/acp-chat-agentic-error-taxonomy.test.ts` for all scheduled deterministic failure fixtures. | No shared mock-agent fixture gap for the scheduled error taxonomy pass. | +| `acp-chat-agentic-layout-stress` | `long-stream`, `stream-rich` | Long content and rich layout subcases exist as separate bounded passes. | Scenario owner should decide whether separate passes are enough; a single combined long-rich fixture remains scenario-specific. | +| `acp-chat-agentic-keyboard-a11y` | `stream-rich`, `history`, `permission` | Tool-card, seeded history, permission fixture, and stable permission dialog dismissal selectors exist. | Scenario owner still needs stable keyboard focus selectors and scheduled keyboard-specific fixture passes. | +| `acp-chat-agentic-debug-log-from-chat` | `stream-rich` | Rich deterministic ACP traffic exists for log correlation. | Product/scenario owner needs debug-log viewer/store pass and redacted render/copy contract. | +| `acp-chat-agentic-theme-persistence` | none | Optional deterministic chat content can use `stream-rich`, but the core contract is not ACP fixture behavior. | Scenario owner needs stable theme/layout preference controls. | +| `acp-chat-agentic-history` | `history`, `stream-rich` | Seeded sessions, stable ordering, rich replay, and normal send fixture exist. Hardened Playwright coverage exists in `tools/playwright/src/tests/acp-chat-agentic-history.test.ts`. | No shared mock-agent fixture gap for the history-backed pass. | +| `acp-chat-agentic-layout-interop` | none | Not an ACP mock-agent contract. | Scenario owner needs stable Agentic/Classic layout switch control; read-only layout checks can proceed separately. | +| `session-mode` | session with `agent` and `chat` modes | Mock-agent session responses include `agent` and `chat` modes and mode updates. | Scenario owner needs to run against deterministic mock session or product must expose required mode state through the full-profile MCP state path. | +| `session-relay` | `history` | `history` now supplies two seeded sessions and bounded replay data; stable permission dialog selectors are available for the relay permission gate. | Scenario owner still needs prepared relay digest state and scheduled relay-specific full-profile coverage. | +| `permission-dialog` | `history`, `permission` | Seeded sessions, deterministic live permission request fixture, stable close/reject selectors, metadata-only permission state checks, and full-profile Playwright coverage exist in `tools/playwright/src/tests/permission-dialog.test.ts`. | No shared mock-agent fixture gap for the direct permission-dialog pass. | +| `webmcp-ide-capability-groups` | none | Not an ACP mock-agent contract. | Scenario owner needs temporary workspace setup and reversible workspace/search/diagnostics/editor mutation matrix. | +| `acp-debug-log` | `stream-rich` | Rich deterministic ACP protocol traffic exists. | Product/scenario owner needs debug-log store/viewer fixture pass and redaction audit support. | +| `acp-error-and-recovery` | `create-failure`, `load-failure`, `send-failure`, `auth-required`, `config-failure`, `process-exit` | Node/service failure fixtures and process-exit coverage exist; visible browser recovery for the deterministic fixture matrix is covered in `tools/playwright/src/tests/acp-chat-agentic-error-taxonomy.test.ts`. | No shared mock-agent fixture gap for the deterministic recovery pass. | + +## Scenario-Specific Requests Not Implemented Here + +- Backend-readiness failure provider for `acp-chat-agentic-fallback`. +- Stable Agentic/Classic layout switch controls for layout scenarios. +- Stable send, recovery, stop/cancel, command picker, attachment picker, history, and keyboard-focus selectors. +- Combined long-rich fixture unless a future scenario proves separate `long-stream` and `stream-rich` passes are insufficient. +- ACP debug log viewer/store redaction contracts. +- Full-profile reversible workspace/search/diagnostics/editor mutation setup. diff --git a/test/bdd/fixtures/acp-agent/README.md b/test/bdd/fixtures/acp-agent/README.md new file mode 100644 index 0000000000..9a8847e8fc --- /dev/null +++ b/test/bdd/fixtures/acp-agent/README.md @@ -0,0 +1,31 @@ +# Mock ACP Agent + +`mock-acp-agent.mjs` is a deterministic stdio ACP agent for BDD and Playwright hardening. It speaks the real ACP transport through `@agentclientprotocol/sdk`, so OpenSumi still uses the normal `AcpThread` process, JSON-RPC, session updates, permission routing, WebMCP injection, and debug-log path. + +Run it directly for help: + +```bash +node test/bdd/fixtures/acp-agent/mock-acp-agent.mjs --help +``` + +Use it in an ACP BDD runtime by overriding the configured ACP agent command: + +```json +{ + "ai.native.agent.defaultType": "claude-agent-acp", + "ai-native.acp.agents": { + "claude-agent-acp": { + "command": "node", + "args": ["test/bdd/fixtures/acp-agent/mock-acp-agent.mjs", "--fixture=stream-rich"], + "streaming": true, + "description": "OpenSumi BDD mock ACP agent" + } + } +} +``` + +The fixture can also be selected with `OPENSUMI_ACP_BDD_FIXTURE`. Supported fixture modes include `stream-rich`, `long-stream`, `permission`, `send-failure`, `create-failure`, `load-failure`, `auth-required`, `config-failure`, `process-exit`, and `history`. + +`stream-rich` exposes deterministic ACP `configOptions` for `bdd-mode`, `bdd-model`, `bdd-thought-level`, and `bdd-web-search`. After `session/set_config_option`, it returns the complete `configOptions` list. During `session/prompt`, it emits a `BDD_CONFIG_SNAPSHOT` and a tool-call `rawInput.configSnapshot` so tests can prove the prompt turn used the selected footer values without asserting LLM-generated content. + +Keep fixture assertions deterministic: assert ACP/UI state, sentinel text prefixed with `BDD_`, fixed command/config metadata, and bounded safe-state responses. Do not add real credentials or LLM output to this agent. diff --git a/test/bdd/fixtures/acp-agent/mock-acp-agent.mjs b/test/bdd/fixtures/acp-agent/mock-acp-agent.mjs new file mode 100755 index 0000000000..1d86569a00 --- /dev/null +++ b/test/bdd/fixtures/acp-agent/mock-acp-agent.mjs @@ -0,0 +1,730 @@ +#!/usr/bin/env node + +import { AgentSideConnection, RequestError, ndJsonStream } from '@agentclientprotocol/sdk'; +import { Readable, Writable } from 'node:stream'; + +const DEFAULT_DELAY_MS = 40; +const DEFAULT_LONG_STREAM_TICKS = 80; +const PROCESS_EXIT_FIXTURE_CODE = 17; + +function parseArgs(argv) { + const options = { + fixture: process.env.OPENSUMI_ACP_BDD_FIXTURE || 'stream-rich', + delayMs: Number(process.env.OPENSUMI_ACP_BDD_DELAY_MS || DEFAULT_DELAY_MS), + longStreamTicks: Number(process.env.OPENSUMI_ACP_BDD_LONG_STREAM_TICKS || DEFAULT_LONG_STREAM_TICKS), + sessionPrefix: process.env.OPENSUMI_ACP_BDD_SESSION_PREFIX || 'bdd-session', + verbose: process.env.OPENSUMI_ACP_BDD_VERBOSE === '1', + help: false, + }; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === '--help' || arg === '-h') { + options.help = true; + } else if (arg === '--fixture') { + options.fixture = argv[++i] || options.fixture; + } else if (arg.startsWith('--fixture=')) { + options.fixture = arg.slice('--fixture='.length); + } else if (arg === '--delay-ms') { + options.delayMs = Number(argv[++i] || options.delayMs); + } else if (arg.startsWith('--delay-ms=')) { + options.delayMs = Number(arg.slice('--delay-ms='.length)); + } else if (arg === '--long-stream-ticks') { + options.longStreamTicks = Number(argv[++i] || options.longStreamTicks); + } else if (arg.startsWith('--long-stream-ticks=')) { + options.longStreamTicks = Number(arg.slice('--long-stream-ticks='.length)); + } else if (arg === '--session-prefix') { + options.sessionPrefix = argv[++i] || options.sessionPrefix; + } else if (arg.startsWith('--session-prefix=')) { + options.sessionPrefix = arg.slice('--session-prefix='.length); + } else if (arg === '--verbose') { + options.verbose = true; + } + } + + if (!Number.isFinite(options.delayMs) || options.delayMs < 0) { + options.delayMs = DEFAULT_DELAY_MS; + } + if (!Number.isFinite(options.longStreamTicks) || options.longStreamTicks < 1) { + options.longStreamTicks = DEFAULT_LONG_STREAM_TICKS; + } + + return options; +} + +const options = parseArgs(process.argv.slice(2)); + +if (options.help) { + console.log(`OpenSumi BDD mock ACP agent + +Usage: + node test/bdd/fixtures/acp-agent/mock-acp-agent.mjs [--fixture stream-rich] + +Options: + --fixture Fixture mode. Also accepts OPENSUMI_ACP_BDD_FIXTURE. + --delay-ms Delay between streamed updates. + --long-stream-ticks Number of long-stream chunks before natural completion. + --session-prefix Prefix for generated session ids. + --verbose Write diagnostics to stderr. + +Fixtures: + stream-rich Content, thought, plan, tool call, config, and usage updates. + long-stream Repeated content chunks until session/cancel or tick limit. + permission Requests visible client permission during prompt. + send-failure Fails deterministically during session/prompt. + create-failure Fails deterministically during session/new. + load-failure Fails deterministically during session/load. + auth-required Raises an ACP auth-required error during session/prompt. + config-failure Fails deterministic session/set_config_option calls. + process-exit Emits prompt updates, then exits the ACP agent process. + history Seeds deterministic list/load session metadata and bounded rich replay updates. +`); + process.exit(0); +} + +const log = (...args) => { + if (options.verbose) { + console.error('[mock-acp-agent]', ...args); + } +}; + +const sleep = (ms = options.delayMs) => new Promise((resolve) => setTimeout(resolve, ms)); +const nowIso = () => new Date().toISOString(); +const text = (value) => ({ type: 'text', text: value }); + +function createConfigOptions(state = {}) { + const values = { + mode: state.mode || 'agent', + model: state.model || 'bdd-small', + thought: state.thought || 'medium', + webSearch: state.webSearch ?? false, + }; + + return [ + { + id: 'bdd-mode', + name: 'BDD Mode', + type: 'select', + category: 'mode', + currentValue: values.mode, + options: [ + { value: 'agent', name: 'Agent' }, + { value: 'chat', name: 'Chat' }, + ], + }, + { + id: 'bdd-model', + name: 'BDD Model', + type: 'select', + category: 'model', + currentValue: values.model, + options: [ + { value: 'bdd-small', name: 'BDD Small' }, + { value: 'bdd-large', name: 'BDD Large' }, + ], + }, + { + id: 'bdd-thought-level', + name: 'BDD Thought Level', + type: 'select', + category: 'thought_level', + currentValue: values.thought, + options: [ + { value: 'low', name: 'Low' }, + { value: 'medium', name: 'Medium' }, + { value: 'high', name: 'High' }, + ], + }, + { + id: 'bdd-web-search', + name: 'BDD Web Search', + type: 'boolean', + category: '_bdd_feature', + currentValue: values.webSearch, + }, + ]; +} + +function createModes(currentModeId = 'agent') { + return { + currentModeId, + availableModes: [ + { id: 'agent', name: 'Agent', description: 'Deterministic agent mode' }, + { id: 'chat', name: 'Chat', description: 'Deterministic chat mode' }, + ], + }; +} + +function createModels(currentModelId = 'bdd-small') { + return { + currentModelId, + availableModels: [ + { modelId: 'bdd-small', name: 'BDD Small', description: 'Fast deterministic model' }, + { modelId: 'bdd-large', name: 'BDD Large', description: 'Verbose deterministic model' }, + ], + }; +} + +function createCommands() { + return [ + { + name: 'bdd_echo', + description: 'Emit a deterministic assistant response.', + input: { hint: 'optional deterministic text' }, + }, + { + name: 'bdd_plan', + description: 'Emit deterministic thought, plan, and tool updates.', + input: { hint: 'optional plan subject' }, + }, + { + name: 'bdd_permission', + description: 'Trigger a deterministic permission request.', + input: { hint: 'optional permission subject' }, + }, + ]; +} + +function createSessionRecord(sessionId, cwd) { + return { + sessionId, + cwd, + title: `BDD Session ${sessionId}`, + updatedAt: nowIso(), + mode: 'agent', + model: 'bdd-small', + thought: 'medium', + webSearch: false, + promptCount: 0, + }; +} + +function createHistorySessionRecord(sessionId, cwd, seed, updatedAt) { + const session = createSessionRecord(sessionId, cwd); + session.title = `BDD History ${seed}`; + session.updatedAt = updatedAt; + session.historySeed = seed; + session.promptCount = 1; + return session; +} + +function responseForSession(session) { + return { + sessionId: session.sessionId, + modes: createModes(session.mode), + models: createModels(session.model), + configOptions: createConfigOptions(session), + }; +} + +function sessionInfo(session) { + return { + sessionId: session.sessionId, + cwd: session.cwd, + title: session.title, + updatedAt: session.updatedAt, + }; +} + +function extractPromptText(prompt) { + if (!Array.isArray(prompt)) { + return ''; + } + return prompt + .map((block) => { + if (block?.type === 'text') { + return block.text || ''; + } + if (block?.type === 'resource_link') { + return block.title || block.name || block.uri || ''; + } + return ''; + }) + .filter(Boolean) + .join('\n'); +} + +function createAgent(conn) { + const sessions = new Map(); + const pendingPrompts = new Map(); + let nextSessionNumber = 1; + + if (options.fixture === 'history' || options.fixture === 'load-failure') { + const seeds = [ + { suffix: 'alpha', updatedAt: '2026-06-11T00:00:01.000Z' }, + { suffix: 'beta', updatedAt: '2026-06-11T00:00:02.000Z' }, + ]; + + for (const { suffix, updatedAt } of seeds) { + const session = createHistorySessionRecord( + `${options.sessionPrefix}-${suffix}`, + process.cwd(), + suffix, + updatedAt, + ); + sessions.set(session.sessionId, session); + } + } + + const emit = async (sessionId, update) => { + await conn.sessionUpdate({ sessionId, update }); + }; + + const emitAvailableCommandsUpdate = async (session) => { + await emit(session.sessionId, { + sessionUpdate: 'available_commands_update', + availableCommands: createCommands(), + }); + }; + + const scheduleAvailableCommandsUpdate = (session) => { + setTimeout(() => { + emitAvailableCommandsUpdate(session).catch((error) => log('available commands update failed', error)); + }, 0); + }; + + const emitInitialSessionUpdates = async (session) => { + await emit(session.sessionId, { + sessionUpdate: 'session_info_update', + title: session.title, + updatedAt: session.updatedAt, + }); + await emitAvailableCommandsUpdate(session); + await emit(session.sessionId, { + sessionUpdate: 'current_mode_update', + currentModeId: session.mode, + }); + await emit(session.sessionId, { + sessionUpdate: 'config_option_update', + configOptions: createConfigOptions(session), + }); + }; + + const getOrCreateSession = (sessionId, cwd = process.cwd()) => { + if (sessions.has(sessionId)) { + return sessions.get(sessionId); + } + const session = createSessionRecord(sessionId, cwd); + sessions.set(sessionId, session); + return session; + }; + + const runRichStream = async (session, promptText) => { + const configSnapshot = { + mode: session.mode, + model: session.model, + thought: session.thought, + webSearch: session.webSearch, + }; + + await emit(session.sessionId, { + sessionUpdate: 'agent_thought_chunk', + content: text('BDD_THOUGHT_STEP_1: inspected deterministic fixture.'), + }); + await emit(session.sessionId, { + sessionUpdate: 'agent_thought_chunk', + content: text( + `BDD_CONFIG_SNAPSHOT mode=${configSnapshot.mode} model=${configSnapshot.model} thought=${configSnapshot.thought} webSearch=${configSnapshot.webSearch}`, + ), + }); + await sleep(); + await emit(session.sessionId, { + sessionUpdate: 'plan', + entries: [ + { content: 'BDD plan: prepare deterministic stream', status: 'completed', priority: 'high' }, + { content: 'BDD plan: emit tool update', status: 'in_progress', priority: 'medium' }, + ], + }); + await sleep(); + await emit(session.sessionId, { + sessionUpdate: 'agent_message_chunk', + content: text(`BDD_ASSISTANT_PART_1 for turn ${session.promptCount}.`), + }); + await sleep(); + await emit(session.sessionId, { + sessionUpdate: 'tool_call', + toolCallId: `bdd-tool-${session.promptCount}`, + title: 'BDD deterministic tool', + kind: 'read', + status: 'pending', + rawInput: { + fixture: options.fixture, + promptChars: promptText.length, + configSnapshot, + }, + }); + await sleep(); + await emit(session.sessionId, { + sessionUpdate: 'tool_call_update', + toolCallId: `bdd-tool-${session.promptCount}`, + status: 'in_progress', + rawInput: { + fixture: options.fixture, + phase: 'in_progress', + }, + }); + await sleep(); + await emit(session.sessionId, { + sessionUpdate: 'tool_call_update', + toolCallId: `bdd-tool-${session.promptCount}`, + status: 'completed', + rawOutput: { + ok: true, + sentinel: 'BDD_TOOL_RESULT', + }, + }); + await sleep(); + await emit(session.sessionId, { + sessionUpdate: 'agent_message_chunk', + content: text(' BDD_ASSISTANT_PART_2 completed.'), + }); + await emit(session.sessionId, { + sessionUpdate: 'usage_update', + size: 4096, + used: 128 + session.promptCount, + }); + }; + + const emitHistoryReplay = async (session) => { + if (options.fixture !== 'history' || !session.historySeed) { + return; + } + + const seed = String(session.historySeed); + const upperSeed = seed.toUpperCase(); + const toolCallId = `bdd-history-${seed}-tool`; + + await emit(session.sessionId, { + sessionUpdate: 'user_message_chunk', + content: text(`BDD_HISTORY_USER_${upperSeed}`), + }); + await emit(session.sessionId, { + sessionUpdate: 'agent_thought_chunk', + content: text(`BDD_HISTORY_THOUGHT_${upperSeed}: deterministic replay.`), + }); + await emit(session.sessionId, { + sessionUpdate: 'plan', + entries: [ + { content: `BDD history ${seed}: restore session`, status: 'completed', priority: 'high' }, + { content: `BDD history ${seed}: keep replay bounded`, status: 'completed', priority: 'medium' }, + ], + }); + await emit(session.sessionId, { + sessionUpdate: 'agent_message_chunk', + content: text(`BDD_HISTORY_ASSISTANT_${upperSeed}_PART_1.`), + }); + await emit(session.sessionId, { + sessionUpdate: 'tool_call', + toolCallId, + title: 'BDD history deterministic tool', + kind: 'read', + status: 'pending', + rawInput: { + fixture: 'history', + sessionSeed: seed, + bounded: true, + }, + }); + await emit(session.sessionId, { + sessionUpdate: 'tool_call_update', + toolCallId, + status: 'completed', + rawOutput: { + ok: true, + sentinel: 'BDD_HISTORY_TOOL_RESULT', + sessionSeed: seed, + }, + }); + await emit(session.sessionId, { + sessionUpdate: 'agent_message_chunk', + content: text(` BDD_HISTORY_ASSISTANT_${upperSeed}_PART_2.`), + }); + await emit(session.sessionId, { + sessionUpdate: 'usage_update', + size: 2048, + used: 96, + }); + }; + + const runLongStream = async (session) => { + let resolveCancel; + const cancelPromise = new Promise((resolve) => { + resolveCancel = resolve; + }); + pendingPrompts.set(session.sessionId, { cancel: resolveCancel }); + + try { + for (let i = 1; i <= options.longStreamTicks; i++) { + await emit(session.sessionId, { + sessionUpdate: 'agent_message_chunk', + content: text(`BDD_LONG_STREAM_CHUNK_${String(i).padStart(2, '0')} `), + }); + + const canceled = await Promise.race([sleep(), cancelPromise.then(() => true)]); + if (canceled === true) { + await emit(session.sessionId, { + sessionUpdate: 'agent_message_chunk', + content: text('BDD_LONG_STREAM_CANCELLED'), + }); + return { stopReason: 'cancelled' }; + } + } + } finally { + pendingPrompts.delete(session.sessionId); + } + + return { stopReason: 'end_turn' }; + }; + + const runPermission = async (session) => { + const toolCallId = `bdd-permission-${session.promptCount}`; + const toolCall = { + toolCallId, + title: 'BDD permission fixture', + kind: 'edit', + status: 'pending', + rawInput: { + fixture: 'permission', + path: 'editor.js', + }, + }; + + await emit(session.sessionId, { sessionUpdate: 'tool_call', ...toolCall }); + const response = await conn.requestPermission({ + sessionId: session.sessionId, + toolCall, + options: [ + { optionId: 'allow_once', name: 'Allow Once', kind: 'allow_once' }, + { optionId: 'reject_once', name: 'Reject', kind: 'reject_once' }, + ], + }); + + const selected = response?.outcome?.outcome === 'selected' ? response.outcome.optionId : 'cancelled'; + const allowed = selected === 'allow_once'; + + await emit(session.sessionId, { + sessionUpdate: 'tool_call_update', + toolCallId, + status: allowed ? 'completed' : 'failed', + rawOutput: { + permissionOutcome: selected, + }, + }); + await emit(session.sessionId, { + sessionUpdate: 'agent_message_chunk', + content: text(allowed ? 'BDD_PERMISSION_ALLOWED' : 'BDD_PERMISSION_REJECTED'), + }); + + return { stopReason: allowed ? 'end_turn' : 'cancelled' }; + }; + + const runProcessExit = async (session) => { + await emit(session.sessionId, { + sessionUpdate: 'agent_thought_chunk', + content: text('BDD_PARTIAL_THOUGHT: prepared deterministic partial turn.'), + }); + await emit(session.sessionId, { + sessionUpdate: 'agent_message_chunk', + content: text('BDD_ASSISTANT_BEFORE_STOP'), + }); + + log(`process-exit fixture exiting with code ${PROCESS_EXIT_FIXTURE_CODE}`); + process.exitCode = PROCESS_EXIT_FIXTURE_CODE; + process.exit(PROCESS_EXIT_FIXTURE_CODE); + }; + + return { + async initialize(params) { + log('initialize', params?.protocolVersion); + return { + protocolVersion: params.protocolVersion, + agentInfo: { + name: 'opensumi-bdd-mock-acp-agent', + title: 'OpenSumi BDD Mock ACP Agent', + version: '1.0.0', + }, + agentCapabilities: { + loadSession: true, + sessionCapabilities: { + list: {}, + loadSession: {}, + }, + mcpCapabilities: { + http: true, + }, + promptCapabilities: { + image: false, + audio: false, + embeddedContext: true, + }, + }, + }; + }, + + async newSession(params) { + if (options.fixture === 'create-failure') { + throw RequestError.internalError({ fixture: options.fixture }, 'BDD create-session failure'); + } + + const sessionId = `${options.sessionPrefix}-${nextSessionNumber++}`; + const session = createSessionRecord(sessionId, params.cwd); + sessions.set(sessionId, session); + await emitInitialSessionUpdates(session); + scheduleAvailableCommandsUpdate(session); + return responseForSession(session); + }, + + async loadSession(params) { + if (options.fixture === 'load-failure') { + throw RequestError.resourceNotFound(params.sessionId); + } + + const session = getOrCreateSession(params.sessionId, params.cwd); + session.updatedAt = nowIso(); + await emitInitialSessionUpdates(session); + await emitHistoryReplay(session); + scheduleAvailableCommandsUpdate(session); + return responseForSession(session); + }, + + async listSessions(params = {}) { + const allSessions = [...sessions.values()] + .filter((session) => !params.cwd || session.cwd === params.cwd) + .sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt))); + return { sessions: allSessions.map(sessionInfo) }; + }, + + async setSessionMode(params) { + const session = getOrCreateSession(params.sessionId); + session.mode = params.modeId; + session.updatedAt = nowIso(); + await emit(params.sessionId, { + sessionUpdate: 'current_mode_update', + currentModeId: session.mode, + }); + return {}; + }, + + async unstable_setSessionModel(params) { + const session = getOrCreateSession(params.sessionId); + session.model = params.modelId || params.model || session.model; + session.updatedAt = nowIso(); + return {}; + }, + + async setSessionConfigOption(params) { + if (options.fixture === 'config-failure') { + throw RequestError.invalidParams({ fixture: options.fixture, configId: params.configId }, 'BDD config failure'); + } + + const session = getOrCreateSession(params.sessionId); + if (params.configId === 'bdd-mode') { + session.mode = params.value; + } else if (params.configId === 'bdd-model') { + session.model = params.value; + } else if (params.configId === 'bdd-thought-level') { + session.thought = params.value; + } else if (params.configId === 'bdd-web-search') { + session.webSearch = params.value; + } + session.updatedAt = nowIso(); + const configOptions = createConfigOptions(session); + await emit(params.sessionId, { + sessionUpdate: 'config_option_update', + configOptions, + }); + return { configOptions }; + }, + + async prompt(params) { + if (options.fixture === 'send-failure') { + throw RequestError.internalError({ fixture: options.fixture }, 'BDD send failure'); + } + if (options.fixture === 'auth-required') { + throw RequestError.authRequired({ fixture: options.fixture }, 'BDD auth required'); + } + + const session = getOrCreateSession(params.sessionId); + session.promptCount += 1; + session.title = `BDD Turn ${session.promptCount}`; + session.updatedAt = nowIso(); + const promptText = extractPromptText(params.prompt); + + await emit(params.sessionId, { + sessionUpdate: 'session_info_update', + title: session.title, + updatedAt: session.updatedAt, + }); + await emit(params.sessionId, { + sessionUpdate: 'user_message_chunk', + content: text(`BDD_USER_TURN_${session.promptCount}`), + }); + await sleep(); + + if (options.fixture === 'long-stream') { + return runLongStream(session); + } + if (options.fixture === 'permission') { + return runPermission(session); + } + if (options.fixture === 'process-exit') { + return runProcessExit(session); + } + + await runRichStream(session, promptText); + return { + stopReason: 'end_turn', + usage: { + inputTokens: Math.max(1, promptText.length), + outputTokens: 32, + totalTokens: Math.max(1, promptText.length) + 32, + thoughtTokens: 4, + }, + }; + }, + + async cancel(params) { + const pending = pendingPrompts.get(params.sessionId); + if (pending) { + pending.cancel(); + } + }, + + async unstable_forkSession(params) { + const source = getOrCreateSession(params.sessionId, params.cwd); + const sessionId = `${source.sessionId}-fork`; + const session = { + ...source, + sessionId, + title: `${source.title} Fork`, + updatedAt: nowIso(), + }; + sessions.set(sessionId, session); + return { sessionId }; + }, + + async unstable_resumeSession(params) { + getOrCreateSession(params.sessionId, params.cwd); + return {}; + }, + + async unstable_closeSession(params) { + sessions.delete(params.sessionId); + return {}; + }, + + async authenticate() { + return {}; + }, + }; +} + +const input = Readable.toWeb(process.stdin); +const output = Writable.toWeb(process.stdout); +const stream = ndJsonStream(output, input); +const connection = new AgentSideConnection((conn) => createAgent(conn), stream); + +connection.closed.catch((error) => { + console.error('[mock-acp-agent] connection failed', error); + process.exitCode = 1; +}); diff --git a/test/bdd/permission-dialog.scenario.md b/test/bdd/permission-dialog.scenario.md new file mode 100644 index 0000000000..1f09a1a37c --- /dev/null +++ b/test/bdd/permission-dialog.scenario.md @@ -0,0 +1,62 @@ +# Scenario: Permission Dialog Observability - Observe Without Deciding + +**Trigger:** `packages/ai-native/src/browser/acp/permission-bridge.service.ts` or `packages/ai-native/src/browser/acp/webmcp-groups/acp-chat.webmcp-group.ts` + +**Layer:** `runtime-ui` **Required profile:** `full` **Fixtures:** Two ACP sessions from `--fixture=history` when relay setup needs seeded sessions, a prepared relay permission request or the mock ACP agent configured as `node test/bdd/fixtures/acp-agent/mock-acp-agent.mjs --fixture=permission` for a visible permission request, and stable permission dialog selectors. A real LLM-backed ACP agent/prompt combination may be used only when it reliably triggers a visible permission request. **Workspace mutation:** None. **Automation status:** Converted to `tools/playwright/src/tests/permission-dialog.test.ts` for the deterministic `permission` fixture, full WebMCP profile, metadata-only permission state, and visible close/reject dismissal. Live-agent runs may cover dialog observability only when the prompt/agent reliably triggers permission. + +## Given + +- Common preflight in `test/bdd/README.md` passes. +- The MCP `opensumi-ide` server is connected. +- `acp_chat_get_permission_state` is available in the default tool list. +- Permission tools are referenced only by canonical `tool.name` values. +- The test environment uses full WebMCP profile for this scenario. +- There are at least two ACP sessions when the relay path is used; direct mock-agent permission observability may run with one active session. + +## When + +### Part A - Baseline Permission State + +1. `mcp`: `acp_chat_get_permission_state({})` -> record `PERMISSION_BASELINE`. +2. `chrome-devtools-mcp-evaluate`: record count of visible ACP permission dialog elements. + +### Part B - Pending Permission Observability + +3. If full-profile relay tools are available, prepare a digest: + ```js + acp_chat_prepare_session_digest({ sourceSessionId }); + ``` +4. Start, but do not await to completion: + ```js + acp_chat_post_prepared_relay({ digestId, targetSessionId }); + ``` +5. While the relay call is pending, poll `acp_chat_get_permission_state({})` -> record `PERMISSION_PENDING`. +6. `chrome-devtools-mcp-evaluate`: record whether the permission dialog is visible and whether it shows user-facing permission text. +7. `chrome-devtools-mcp`: click the visible Reject or close control in the permission dialog. Do not use an ACP tool to decide. +8. `mcp`: `acp_chat_get_permission_state({})` -> record `PERMISSION_AFTER_DISMISS`. + +If relay setup is unavailable but the mock `permission` fixture is configured, trigger the permission request by sending a deterministic prompt through the Agentic input, then execute Steps 5-8 against the visible dialog and permission state. + +## Then + +- Step 1 returns `success: true`. +- `PERMISSION_BASELINE.result.activeDialogCount` is a number. +- `PERMISSION_BASELINE.result.activeSessionId` is either a string or null/undefined. +- `PERMISSION_BASELINE.result.pendingCountExcludingActive` is a number. +- Step 1 response does not include request content, file contents, or permission options. +- If Part B runs, Step 5 observes `activeDialogCount >= 1` while the dialog is visible. +- If Part B runs, Step 6 confirms the dialog is visible in the browser. +- If Part B runs, Step 8 eventually returns to the baseline active dialog count. +- No step uses or expects `acp_handlePermissionDialog`. +- No operational step invokes a legacy `_opensumi/acp_chat/*` identifier, and the runtime must not accept one as an alias. + +## Live Agent Execution + +- A real LLM-backed ACP agent may be used to observe a live permission dialog, permission counts/session id, and browser-only dismissal. +- Live-agent mode must not assert permission body text, hidden decision options, model tool arguments/results, or generated assistant content. If a live prompt does not produce a dialog, the pending-permission portion is blocked and should not be marked passed. + +## Pass / Fail Judgment + +- **PASS** - permission state is observable as counts/session id only, and pending dialogs are visible through both MCP state and Chrome DevTools MCP DOM. +- **BLOCKED** - the run lacks full profile, both relay setup and the mock ACP agent `permission` fallback, or a stable permission dialog selector for the Reject/close control. +- **FAIL** - permission state is unavailable, leaks permission content, or exposes an automated approve/reject ACP tool. diff --git a/test/bdd/session-mode.scenario.md b/test/bdd/session-mode.scenario.md new file mode 100644 index 0000000000..622d96045c --- /dev/null +++ b/test/bdd/session-mode.scenario.md @@ -0,0 +1,48 @@ +# Scenario: Session Mode - Full Profile Switch Return Contract + +**Trigger:** `packages/ai-native/src/browser/acp/webmcp-groups/acp-chat.webmcp-group.ts` or `packages/ai-native/src/browser/chat/chat.internal.service.acp.ts` + +**Layer:** `mcp-contract` **Required profile:** `full` **Fixtures:** Fresh MCP session in a full profile that exposes ACP Chat mode tools and a session whose modes include `agent` and `chat`. **Workspace mutation:** None. **Automation status:** Automated MCP contract spec for the current tool return contract; active-mode observability through `acp_chat_get_session_state` is not required until the state schema exposes `currentModeId`. + +## Given + +- Common preflight in `test/bdd/README.md` passes. +- The MCP `opensumi-ide` server is connected. +- The IDE is running with `ai.native.webmcp.profile = "full"`. +- `acp_chat_set_session_mode` and `acp_chat_get_session_state` are callable directly or through `opensumi_invoke_capability_tool`. + +## When + +1. `mcp`: `acp_chat_show_chat_view({})`. +2. `chrome-devtools-mcp-wait`: wait until the chat view is visible and an active session exists. +3. `mcp`: `acp_chat_get_session_state({})` -> record `STATE_INITIAL`. +4. `mcp`: `acp_chat_set_session_mode({ modeId: "agent" })` -> record `SET_AGENT`. +5. `mcp`: `acp_chat_get_session_state({})` -> record `STATE_AGENT`. +6. `mcp`: `acp_chat_set_session_mode({ modeId: "chat" })` -> record `SET_CHAT`. +7. `mcp`: `acp_chat_get_session_state({})` -> record `STATE_CHAT`. +8. Evaluate the safe session-state shape: + ```js + ({ + agentKeys: Object.keys(STATE_AGENT?.result?.session || {}), + chatKeys: Object.keys(STATE_CHAT?.result?.session || {}), + hasModeField: + 'currentModeId' in (STATE_AGENT?.result?.session || {}) || + 'modeId' in (STATE_AGENT?.result?.session || {}) || + 'sessionMode' in (STATE_AGENT?.result?.session || {}), + }); + ``` + +## Then + +- Step 3 returns `success: true` with `active: true`. +- Step 4 returns `success: true` and `result.modeId === "agent"`. +- Step 5 returns `success: true`. +- Step 6 returns `success: true` and `result.modeId === "chat"`. +- Step 7 returns `success: true`. +- Step 8 records the returned session summary keys for audit. With the current schema, `hasModeField` may be `false`; that is not a failure for this scenario. +- `acp_chat_get_session_state` remains metadata-only. Bounded session title metadata is allowed, but message bodies, assistant text, tool-call output, and config option secrets are not. + +## Pass / Fail Judgment + +- **PASS** - full-profile exposure is present, mode-switch calls return the requested `modeId`, and session-state reads remain active and metadata-only. +- **FAIL** - full-profile exposure is missing, `acp_chat_set_session_mode` fails its current return contract, or session state leaks message/config content. diff --git a/test/bdd/session-relay.scenario.md b/test/bdd/session-relay.scenario.md new file mode 100644 index 0000000000..e18edc2c83 --- /dev/null +++ b/test/bdd/session-relay.scenario.md @@ -0,0 +1,80 @@ +# Scenario: Session Relay - Digest Preview, Permission Gate, and Bounded Reads + +**Trigger:** `packages/ai-native/src/browser/acp/acp-chat-relay-*.ts` or `packages/ai-native/src/browser/acp/webmcp-groups/acp-chat.webmcp-group.ts` + +**Layer:** `mcp-contract` **Required profile:** `full` **Fixtures:** Two ACP sessions from the mock ACP agent `--fixture=history` with bounded history or deterministic sends, prepared relay digest state, or live sessions created through a real LLM-backed ACP agent only for bounded relay smoke coverage; stable permission dialog selectors are required when posting the relay. **Workspace mutation:** None. **Automation status:** Automated through MCP plus Chrome DevTools MCP; live-agent sessions may supply bounded metadata, but the mock `history` fixture or equivalent stable setup is required for prepared relay digest and permission-gate hardening. + +## Given + +- Common preflight in `test/bdd/README.md` passes. +- The MCP `opensumi-ide` server is connected. +- The scenario is scheduled only when `ai.native.webmcp.profile = "full"`. +- ACP Chat relay and bounded debug read tools are exposed by the full profile. +- There are at least two ACP sessions, preferably seeded by the mock `history` fixture: + - `sourceSessionId` + - `targetSessionId` +- The relay post and bounded debug read steps run in the same full-profile pass. + +## When + +### Part A - Discover Sessions + +1. `mcp`: `acp_chat_list_sessions({})` -> record `SESSIONS`. + +### Part B - Prepare Digest + +2. `mcp`: `acp_chat_prepare_session_digest({ sourceSessionId, maxSourceChars: 12000, maxDigestChars: 2000 })` -> record `DIGEST`. + +### Part C - Post Digest With Permission + +3. Start: + ```js + acp_chat_post_prepared_relay({ digestId: DIGEST.result.digestId, targetSessionId }); + ``` +4. `chrome-devtools-mcp-wait`: wait until the permission dialog is visible. +5. `mcp`: `acp_chat_get_permission_state({})` -> record `PERMISSION_DURING_RELAY`. +6. `chrome-devtools-mcp`: click the visible Reject or close control in the permission dialog. +7. Await the relay tool call -> record `POST_RESULT`. + +### Part D - Bounded Debug Read + +8. If `acp_chat_read_session_messages` is exposed in the full profile, call: + ```js + acp_chat_read_session_messages({ sessionId: sourceSessionId, maxMessages: 10, maxChars: 4000 }); + ``` + -> record `READ_RESULT`. + +## Then + +- Step 1 returns `success: true` with `sessions` metadata and `total`. +- Session metadata may include bounded title fields, but must not include full prompt/message bodies, assistant response content, or tool-call result content. +- Step 2 returns `success: true`. +- `DIGEST.result` contains: + - `digestId` + - `sourceSessionId` + - `sourceTitle` + - `digestSource` + - `preview` + - `digestChars` + - `sourceChars` + - `sourceTruncated` + - `expiresAt` +- `DIGEST.result` must not include a full `digest` field. +- `DIGEST.result.preview.length <= 300`. +- If Part C runs, Step 4 shows a permission dialog before relay posting completes. +- If Part C runs, Step 5 observes `activeDialogCount >= 1`. +- If Part C is rejected, Step 7 returns `success: false` and `error: "PERMISSION_DENIED"`. +- If Part C is allowed in a separate run, the response must include `posted`, `digestId`, `sourceSessionId`, `targetSessionId`, `digestChars`, and `switchedSession`. +- If Part D runs, `READ_RESULT.result.messages` contains only `user` and `assistant` roles, bounded by `maxMessages` and `maxChars`. +- Part D must not return tool-result messages. + +## Live Agent Execution + +- A real LLM-backed ACP agent may create source and target sessions for relay smoke coverage, list metadata, bounded digest preview shape, and permission-gate observability. +- Live-agent mode must not assert full digest bodies, exact generated session titles, assistant response text, model tool results, or exact message contents. Permission posting still requires a stable visible Reject/close selector, and bounded debug reads must remain redacted evidence only. + +## Pass / Fail Judgment + +- **PASS** - relay preparation returns only bounded metadata/preview, relay posting is permission-gated, and full-profile message reads are bounded. +- **BLOCKED** - the run is not full profile, lacks two ACP sessions from the mock `history` fixture or equivalent stable setup, or lacks a stable permission dialog selector for the Reject/close control. +- **FAIL** - prepare returns full digest/source content, post bypasses permission, or debug reads return unbounded/tool-result content. diff --git a/test/bdd/terminal-file-tree-refresh.scenario.md b/test/bdd/terminal-file-tree-refresh.scenario.md new file mode 100644 index 0000000000..130dfb8565 --- /dev/null +++ b/test/bdd/terminal-file-tree-refresh.scenario.md @@ -0,0 +1,95 @@ +# Scenario: Terminal File Tree Refresh - Terminal-Created File Appears In Explorer + +**Trigger:** `packages/file-service/src/node/hosted/recursive/file-service-watcher.ts`, `packages/file-service/src/node/watcher-process-manager.ts`, `packages/file-tree-next/src/browser/file-tree.service.ts`, `packages/terminal-next`, or `packages/ai-native/src/browser/acp/webmcp-groups/terminal.webmcp-group.ts` + +**Layer:** `runtime-ui` **Required profile:** `full` **Fixtures:** IDE dev server opened with the default workspace, Explorer visible, full-profile file and terminal WebMCP tools exposed, and a POSIX-compatible terminal profile. **Workspace mutation:** One temporary root-level file named `terminal-file-tree-refresh-.txt`, deleted before the scenario ends. **Automation status:** Automated through Chrome DevTools MCP plus browser WebMCP/MCP calls; convert to Playwright once Explorer selectors and terminal profile setup are stable in CI. + +## Given + +- Common preflight in `test/bdd/README.md` passes with: + ```text + http://localhost:8080/?workspaceDir=&webMcpProfile=full + ``` +- Explorer is visible and the file tree root is loaded. +- Browser `navigator.modelContext` is available, or the MCP `opensumi-ide` server is connected. +- The active profile exposes full-profile terminal and file tools: + - `file_get_workspace_root` + - `file_exists` + - `terminal_create` + - `terminal_show` + - `terminal_resize` + - `terminal_get_process_info` + - `terminal_run_command` + - `terminal_read_output` + - `terminal_wait_for_pattern` +- The scenario chooses a unique `RUN_ID` and sets: + ```text + REL_FILE = terminal-file-tree-refresh-.txt + MARKER_CWD = TREE_CWD_ + MARKER_CREATE = TREE_CREATE_ + MARKER_DELETE = TREE_DELETE_ + ``` + Marker commands must emit the marker by splitting the static prefix and `RUN_ID` into separate shell words, so `terminal_wait_for_pattern` matches command output rather than the terminal's echoed command line. + +## When + +### Part A - Setup And Terminal CWD + +1. `webmcp`: call `file_get_workspace_root({})` and record `WORKSPACE_ROOT`. +2. `webmcp`: call `file_exists({ path: REL_FILE })`. +3. If `REL_FILE` exists from a prior interrupted run, choose a new `RUN_ID` and repeat the `file_exists({ path: REL_FILE })` pre-check. +4. `chrome-devtools-mcp`: assert Explorer does not display `REL_FILE`. +5. `webmcp`: call `terminal_create({})` and record `TERMINAL_ID`. +6. `webmcp`: call `terminal_show({ id: TERMINAL_ID })`. +7. `webmcp`: call `terminal_resize({ id: TERMINAL_ID, cols: 200, rows: 24 })` so marker lines are not split by a narrow panel. +8. `webmcp`: poll `terminal_get_process_info({ id: TERMINAL_ID })` until `ready` is `true`, and record the reported `cwd`. +9. `webmcp`: call `terminal_run_command({ id: TERMINAL_ID, command: "pwd && printf 'TREE_CWD_' && printf '\\n'" })`. +10. `webmcp`: call `terminal_wait_for_pattern({ id: TERMINAL_ID, pattern: MARKER_CWD, timeoutMs: 10000 })`. +11. `webmcp`: call `terminal_read_output({ id: TERMINAL_ID, maxLines: 120 })` and record the `pwd` output. + +### Part B - Create File From Terminal + +12. `webmcp`: call: + +```js +terminal_run_command({ + id: TERMINAL_ID, + command: "printf 'created from terminal\\n' > '' && printf 'TREE_CREATE_' && printf '\\n'", +}); +``` + +13. `webmcp`: wait for `MARKER_CREATE` in terminal output. +14. `webmcp`: call `file_exists({ path: REL_FILE })`. +15. `chrome-devtools-mcp`: without invoking Explorer Refresh, reloading the page, or using a file WebMCP mutation tool for `REL_FILE`, wait up to `5000ms` for the Explorer file tree to display `REL_FILE`. + +### Part C - Delete File From Terminal + +16. `webmcp`: call: + +```js +terminal_run_command({ + id: TERMINAL_ID, + command: "rm -f '' && printf 'TREE_DELETE_' && printf '\\n'", +}); +``` + +17. `webmcp`: wait for `MARKER_DELETE` in terminal output. +18. `webmcp`: call `file_exists({ path: REL_FILE })`. +19. `chrome-devtools-mcp`: without invoking Explorer Refresh or reloading the page, wait up to `5000ms` for the Explorer file tree to stop displaying `REL_FILE`. +20. `webmcp`: call `terminal_run_command({ id: TERMINAL_ID, command: "exit" })`. + +## Then + +- The terminal created by `terminal_create({})` is ready and starts in `WORKSPACE_ROOT`, or the scenario records a terminal default-CWD regression. +- After the create command completes, `file_exists({ path: REL_FILE })` returns `true`. +- The Explorer file tree displays `REL_FILE` automatically after the terminal-created file appears on disk. +- The Explorer assertion must pass without manual Refresh, page reload, `file_create`, `file_write`, or direct file-tree service calls. +- After the delete command completes, `file_exists({ path: REL_FILE })` returns `false`. +- The Explorer file tree removes `REL_FILE` automatically after the terminal-deleted file disappears from disk. +- Terminal output captures only bounded command output and marker text; evidence must not include secrets or full MCP token URLs. + +## Pass / Fail Judgment + +- **PASS** - the terminal default cwd is the workspace root, terminal-created and terminal-deleted files are reflected in Explorer automatically, and cleanup succeeds. +- **BLOCKED** - the run lacks full profile, terminal tools, file tools, Explorer DOM selectors, a POSIX-compatible terminal profile, or a loaded workspace root. +- **FAIL** - the terminal command succeeds and file-service existence checks reflect the disk state, but Explorer does not update until manual refresh or reload; the terminal starts outside the workspace root; or cleanup leaves the temporary file visible in Explorer. diff --git a/test/bdd/webmcp-capability-surface.scenario.md b/test/bdd/webmcp-capability-surface.scenario.md new file mode 100644 index 0000000000..ffb685691b --- /dev/null +++ b/test/bdd/webmcp-capability-surface.scenario.md @@ -0,0 +1,55 @@ +# Scenario: WebMCP Capability Surface - Canonical Names on Browser and MCP + +**Trigger:** `packages/ai-native/src/browser/acp/webmcp-group-registry.ts`, `packages/ai-native/src/browser/acp/webmcp-model-context-adapter.ts`, or `packages/ai-native/src/node/acp/opensumi-mcp-http-server.ts` + +**Layer:** `mcp-contract` **Required profile:** `interactive` or `full` **Fixtures:** Browser `navigator.modelContext`, fresh MCP session connected to `opensumi-ide`, and a workspace containing `editor.js`. **Workspace mutation:** None. **Automation status:** Automated MCP/browser surface contract; blocked if either surface is unavailable. + +## Given + +- Common preflight in `test/bdd/README.md` passes. +- `navigator.modelContext` exists. Native browser implementations and the OpenSumi polyfill are both acceptable. +- The MCP `opensumi-ide` server is connected. +- Use a fresh MCP client session for this scenario so transport-local catalog helper state does not leak in from another scenario. +- The active workspace contains a small existing file `editor.js`. + +## When + +1. `chrome-devtools-mcp-evaluate`: collect browser tools: + ```js + navigator.modelContext + .getTools() + .map((tool) => tool.name) + .sort(); + ``` + -> record `BROWSER_TOOL_NAMES`. +2. `mcp`: `tools/list` -> record `MCP_TOOL_NAMES`. +3. `mcp`: `opensumi_discover_capabilities({ task: "compare webmcp surfaces", includeDisabled: true })` -> record `CATALOG`. +4. `mcp`: `opensumi_describe_tool({ tool: "file_read" })` -> record `FILE_READ_DESCRIPTION`. +5. `mcp`: `opensumi_describe_tool({ tool: "_opensumi/file/read" })` -> record `LEGACY_FILE_READ_DESCRIPTION`. +6. If `file_read` is present in both surfaces, call the browser surface with a small existing file: + ```js + navigator.modelContext.executeTool('file_read', { path: 'editor.js' }); + ``` + -> record `BROWSER_FILE_READ`. +7. If `file_read` is present in MCP `tools/list`, call the MCP surface: + ```js + file_read({ path: 'editor.js' }); + ``` + -> record `MCP_FILE_READ`. + +## Then + +- Step 1 succeeds and every browser tool name is a canonical underscore name. +- Step 2 succeeds and every OpenSumi capability tool name is a canonical underscore name. +- Neither `BROWSER_TOOL_NAMES` nor `MCP_TOOL_NAMES` contains a name that starts with `_opensumi/`. +- `BROWSER_TOOL_NAMES` and the default non-catalog MCP capability tools contain the same default-loaded canonical WebMCP tool names, subject to the active profile. +- Step 3 catalog entries use canonical `tool.name` values only. +- Step 4 succeeds for `file_read`. +- Step 5 fails with `TOOL_NOT_FOUND` or an equivalent structured not-found response. It must not resolve `_opensumi/file/read` as an alias. +- If Steps 6 and 7 run, both calls execute the same capability and return the same success/failure class for the same input. + +## Pass / Fail Judgment + +- **PASS** - browser `navigator.modelContext` and the Node MCP server expose the same canonical WebMCP names, and legacy `_opensumi/...` identifiers are not accepted. +- **BLOCKED** - either browser ModelContext, the Node MCP bridge, or an interactive/full profile tool surface is unavailable. +- **FAIL** - either surface exposes a legacy `_opensumi/...` name, accepts a legacy alias, or diverges from the shared registry naming contract. diff --git a/test/bdd/webmcp-ide-capability-groups.scenario.md b/test/bdd/webmcp-ide-capability-groups.scenario.md new file mode 100644 index 0000000000..9c20a5d3df --- /dev/null +++ b/test/bdd/webmcp-ide-capability-groups.scenario.md @@ -0,0 +1,139 @@ +# Scenario: WebMCP IDE Capability Groups - Workspace, Search, Diagnostics, File, Terminal, Editor + +**Trigger:** `packages/ai-native/src/browser/acp/webmcp-groups/*.webmcp-group.ts`, `packages/ai-native/src/browser/acp/webmcp-group-registry.ts`, or `packages/ai-native/src/node/acp/opensumi-mcp-http-server.ts` + +**Layer:** `mcp-contract` **Required profile:** `full` **Fixtures:** Fresh MCP session, workspace containing a small `package.json`, and a temporary workspace path for reversible mutation checks. **Workspace mutation:** Temporary files under `.tmp/acp-bdd` only. **Automation status:** Automated MCP contract spec; default/interactive runs should skip this full-profile scenario. + +## Given + +- Common preflight in `test/bdd/README.md` passes. +- The MCP `opensumi-ide` server is connected. +- Use a fresh MCP client session so transport-local catalog helper state does not leak from another scenario. +- The workspace contains a small `package.json`. +- The IDE can open an editor for `package.json`. +- Shell or terminal mutation steps run only in a full profile, or are skipped explicitly as profile-gated. + +## Live Agent Execution + +- A real LLM-backed ACP agent is not applicable to this scenario. The contract is the IDE WebMCP capability surface plus reversible workspace/file/editor/terminal fixtures. +- Missing workspace fixtures such as `package.json`, profile-gated tool exposure, or temporary mutation setup must be fixed directly; live-agent availability must not unblock or satisfy these assertions. + +## When + +### Part A - Catalog + +1. `mcp`: `opensumi_discover_capabilities({ task: "inspect IDE context", includeDisabled: true })`. +2. For each group, call `opensumi_describe_capability_group({ group, includeSchemas: true })`: + - `workspace` + - `search` + - `diagnostics` + - `file` + - `terminal` + - `editor` +3. For each canonical tool name, call `opensumi_describe_tool({ tool })`. +4. For representative legacy names such as `_opensumi/file/read` and `_opensumi/editor/getActive`, call `opensumi_describe_tool`. + +### Part B - Workspace And Search + +5. Call `workspace` group tools exposed by the active profile: + - `workspace_get_info({})` + - `workspace_list_open_files({})` + - `workspace_list_recent_workspaces({})` + - record `WORKSPACE_ROOT` from `workspace_get_info.result.workspaceDir` or the first root path +6. Call `search` group tools exposed by the active profile: + - `search_files({ query: "package" })` + - `search_text({ query: "name", include: ["package.json"], maxResults: 20 })` + - `search_symbols({ query: "Acp" })` + +### Part C - Diagnostics And File + +7. Call `diagnostics` group tools exposed by the active profile: + - `diagnostics_list({})` + - `diagnostics_get_stats({})` +8. If diagnostics exist, call `diagnostics_open` for one diagnostic. +9. Call `file` group tools exposed by the active profile: + - `file_get_workspace_root({})` + - `file_exists({ path: "package.json" })` + - `file_stat({ path: "package.json" })` + - `file_read({ path: "package.json" })` + - `file_list({ path: "." })` +10. In full profile only, call reversible file mutation tools under a temporary workspace path: + - `file_create({ path: ".tmp/acp-bdd/source.txt", content: "hello" })` + - `file_write({ path: ".tmp/acp-bdd/source.txt", content: "updated" })` + - `file_create({ path: ".tmp/acp-bdd/editor.ts", content: "function acpBdd() {\n return 1;\n}\n" })` + - `file_copy({ sourcePath: ".tmp/acp-bdd/source.txt", targetPath: ".tmp/acp-bdd/copy.txt" })` + - `file_move({ sourcePath: ".tmp/acp-bdd/copy.txt", targetPath: ".tmp/acp-bdd/moved.txt" })` + - `file_delete({ path: ".tmp/acp-bdd/source.txt" })` + - `file_delete({ path: ".tmp/acp-bdd/moved.txt" })` + +### Part D - Editor + +11. Derive absolute editor paths from `WORKSPACE_ROOT`: + - `PACKAGE_ABS = WORKSPACE_ROOT + "/package.json"` + - `TEMP_EDITOR_ABS = WORKSPACE_ROOT + "/.tmp/acp-bdd/editor.ts"` +12. Call `editor` group tools exposed by the active profile: + - `editor_open({ path: PACKAGE_ABS })` + - `editor_get_active({})` + - `editor_list_open_files({})` + - `editor_get_selection({})` + - `editor_read_buffer({})` + - `editor_read_range_from_buffer({ path: PACKAGE_ABS, startLine: 1, endLine: 20 })` + - `editor_list_dirty_files({})` + - `editor_get_dirty_diff({ path: PACKAGE_ABS })` +13. In full profile only, call safe editor write/UI tools with reversible input: + - `editor_set_selection({ path: TEMP_EDITOR_ABS, startLine: 1 })` + - `editor_format({ path: TEMP_EDITOR_ABS })` + - `editor_fold({ path: TEMP_EDITOR_ABS, startLine: 1 })` + - `editor_unfold({ path: TEMP_EDITOR_ABS, startLine: 1 })` + - `editor_save({ path: TEMP_EDITOR_ABS })` +14. Close editors and clean up the remaining temporary editor file: + - `editor_close({ path: PACKAGE_ABS })` + - `editor_close({ path: TEMP_EDITOR_ABS })` + - `file_delete({ path: ".tmp/acp-bdd/editor.ts" })` + +### Part E - Terminal + +15. Call `terminal` group read/UI tools exposed by the active profile: + - `terminal_list({})` + - `terminal_get_active({})` + - `terminal_get_os({})` + - `terminal_get_profiles({})` + - `terminal_show_panel({})` +16. In full profile only, create a terminal and call: + - `terminal_create({})` and record `TERMINAL_ID = result.id` + - `terminal_show({ id: TERMINAL_ID })` + - `terminal_execute_command({ id: TERMINAL_ID, command: "pwd\n" })` + - `terminal_read_output({ id: TERMINAL_ID, maxLines: 120 })` + - `terminal_tail({ id: TERMINAL_ID, maxLines: 20 })` + - `terminal_get_process_info({ id: TERMINAL_ID })` + - `terminal_get_process_id({ id: TERMINAL_ID })` + - `terminal_wait_for_pattern({ id: TERMINAL_ID, pattern: "." })` + - `terminal_send_text({ id: TERMINAL_ID, text: "" })` + - `terminal_send_control({ id: TERMINAL_ID, key: "ctrl-c" })` + - `terminal_resize({ id: TERMINAL_ID, cols: 80, rows: 24 })` + - `terminal_run_command({ id: TERMINAL_ID, command: "pwd" })` + - `terminal_dispose({ id: TERMINAL_ID })` + +## Then + +- Discovery lists all six IDE groups with canonical underscore tool names only. +- Each described group returns schemas for its tools without exposing workspace file contents or editor buffer contents in the catalog response. +- Legacy `_opensumi/...` names fail with `TOOL_NOT_FOUND` or equivalent. +- Profile-granted tools for default-loaded groups are callable in the current MCP session without requiring `opensumi_enable_capability_group`. +- Profile-forbidden tools are absent from `tools/list` or fail with a structured boundary error, and the optional catalog helper cannot override the active profile. +- Transport-local catalog helper state does not change the profile boundary for another MCP transport session. +- Workspace responses contain metadata such as roots and open files, not file contents. +- Search responses are bounded and include paths/ranges/snippets only within configured limits. +- Diagnostics responses are bounded and include severity, path, range, and message metadata. +- File read/list/stat/exists operations are workspace-scoped and reject path traversal outside the workspace. `file_read` and `file_list` currently do not accept `maxBytes` or `limit`, so this scenario uses a small fixture file and small fixture workspace. +- File mutation operations are unavailable outside full profile and, when run, are limited to the temporary workspace path created by this scenario. +- Editor read operations return active-editor metadata or bounded buffer/range content only for open editor resources. +- Editor write/UI operations are unavailable outside full profile. +- Terminal shell/mutation operations are unavailable outside full profile. +- Terminal operations are bounded, require a valid terminal id when applicable, and clean up created terminals. + +## Pass / Fail Judgment + +- **PASS** - every registered IDE WebMCP capability group is discoverable, profile-gated, and its representative tools execute with bounded, canonical responses. +- **BLOCKED** - the scenario is scheduled without the required full profile, so reversible file/editor/terminal mutation checks cannot be exercised. +- **FAIL** - a registered group is missing from discovery, legacy aliases work, profile-granted tools require a catalog helper, profile-forbidden tools are callable, or file/editor/terminal responses are unbounded or workspace-unsafe. diff --git a/tools/playwright/src/editor.ts b/tools/playwright/src/editor.ts index 2324f1feaf..24aae825ea 100644 --- a/tools/playwright/src/editor.ts +++ b/tools/playwright/src/editor.ts @@ -76,7 +76,7 @@ export class OpenSumiEditor extends OpenSumiView { await this.page.waitForTimeout(200); } - async close() { + async close(options?: { force?: boolean }) { const currentTab = await this.getTabElement(); await currentTab?.hover({ position: { @@ -85,7 +85,7 @@ export class OpenSumiEditor extends OpenSumiView { }, }); const closeIcon = await currentTab?.$("[class*='close_tab___']"); - await closeIcon?.click(); + await closeIcon?.click({ force: options?.force ?? false }); } async saveAndClose() { diff --git a/tools/playwright/src/tests/acp-bdd-fixture.test.ts b/tools/playwright/src/tests/acp-bdd-fixture.test.ts new file mode 100644 index 0000000000..c22888cf4a --- /dev/null +++ b/tools/playwright/src/tests/acp-bdd-fixture.test.ts @@ -0,0 +1,79 @@ +import { promises as fs } from 'fs'; +import os from 'os'; +import path from 'path'; + +import { expect, test } from '@playwright/test'; + +import { + ACP_BDD_BACKEND_READY_FAILURE_QUERY_PARAM, + ACP_BDD_BACKEND_READY_FAILURE_QUERY_VALUE, + aiNativeWorkbenchUrl, + writeMockAcpAgentSettings, +} from './utils/acp-bdd-fixture'; + +async function readSettings(workspaceDir: string) { + return JSON.parse(await fs.readFile(path.join(workspaceDir, '.sumi/settings.json'), 'utf8')); +} + +function readDefaultAgent(settings: any) { + const agentType = settings['ai.native.agent.defaultType']; + return settings['ai-native.acp.agents'][agentType]; +} + +test.describe('ACP BDD fixture scheduling', () => { + test('writes fixture-specific mock ACP agent commands into workspace settings', async () => { + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opensumi-acp-bdd-fixture-')); + + try { + await writeMockAcpAgentSettings(workspaceDir, { + fixture: 'stream-rich', + delayMs: 5, + sessionPrefix: 'bdd-rich', + }); + + let settings = await readSettings(workspaceDir); + let agent = readDefaultAgent(settings); + expect(settings['ai.native.agent.defaultType']).toBe('claude-agent-acp'); + expect(agent.args).toEqual(expect.arrayContaining(['--fixture=stream-rich', '--delay-ms=5'])); + expect(agent.env).toMatchObject({ + OPENSUMI_ACP_BDD_FIXTURE: 'stream-rich', + OPENSUMI_ACP_BDD_DELAY_MS: '5', + OPENSUMI_ACP_BDD_SESSION_PREFIX: 'bdd-rich', + }); + + await writeMockAcpAgentSettings(workspaceDir, { + fixture: 'long-stream', + delayMs: 1, + longStreamTicks: 3, + sessionPrefix: 'bdd-long', + }); + + settings = await readSettings(workspaceDir); + agent = readDefaultAgent(settings); + expect(agent.args).toEqual( + expect.arrayContaining(['--fixture=long-stream', '--delay-ms=1', '--long-stream-ticks=3']), + ); + expect(agent.args).not.toContain('--fixture=stream-rich'); + expect(agent.env).toMatchObject({ + OPENSUMI_ACP_BDD_FIXTURE: 'long-stream', + OPENSUMI_ACP_BDD_DELAY_MS: '1', + OPENSUMI_ACP_BDD_LONG_STREAM_TICKS: '3', + OPENSUMI_ACP_BDD_SESSION_PREFIX: 'bdd-long', + }); + } finally { + await fs.rm(workspaceDir, { recursive: true, force: true }); + } + }); + + test('adds the backend readiness failure query only when requested', async () => { + const defaultUrl = aiNativeWorkbenchUrl('/tmp/workspace'); + const fallbackUrl = aiNativeWorkbenchUrl('/tmp/workspace', 'default', 'agentic', { + forceAcpBackendReadyFailure: true, + }); + + expect(defaultUrl).not.toContain(ACP_BDD_BACKEND_READY_FAILURE_QUERY_PARAM); + expect(fallbackUrl).toContain( + `${ACP_BDD_BACKEND_READY_FAILURE_QUERY_PARAM}=${ACP_BDD_BACKEND_READY_FAILURE_QUERY_VALUE}`, + ); + }); +}); diff --git a/tools/playwright/src/tests/acp-chat-agentic-cancel-stop.test.ts b/tools/playwright/src/tests/acp-chat-agentic-cancel-stop.test.ts new file mode 100644 index 0000000000..fbd5eb26f9 --- /dev/null +++ b/tools/playwright/src/tests/acp-chat-agentic-cancel-stop.test.ts @@ -0,0 +1,143 @@ +// Source: test/bdd/acp-chat-agentic-cancel-stop.scenario.md + +import { expect } from '@playwright/test'; + +import test, { page } from './hooks'; +import { + ACP_BDD_FIXTURE_HOOK_TIMEOUT_MS, + type AcpBddFixtureRuntime, + loadAcpBddFixtureWorkbench, +} from './utils/acp-bdd-fixture'; +import { createBddEvidence } from './utils/bdd-evidence'; + +const LONG_STREAM_PROMPT = 'BDD cancel stop long stream'; +const ACTIVE_STREAM_SENTINEL = 'BDD_LONG_STREAM_CHUNK_02'; +const POST_CANCEL_DRAFT = 'BDD post cancel draft'; + +let runtime: AcpBddFixtureRuntime; + +function chatSlot() { + return page.locator('.AI-Chat-slot'); +} + +async function loadLongStreamWorkbench() { + runtime = await loadAcpBddFixtureWorkbench(page, { + fixture: 'long-stream', + profile: 'interactive', + delayMs: 40, + longStreamTicks: 120, + showChatView: true, + ensureAgenticLayout: true, + viewport: { width: 1600, height: 900 }, + }); + await expect(page.getByRole('heading', { name: 'AI Assistant' })).toBeVisible(); +} + +function chatInput() { + return chatSlot().locator('[contenteditable="true"]').last(); +} + +function chatButton(name: string) { + return chatSlot().getByRole('button', { name }); +} + +async function sendPrompt(prompt: string) { + const input = chatInput(); + await expect(input).toBeVisible(); + await input.click(); + await page.keyboard.type(prompt); + await chatButton('Send').click(); +} + +test.describe('ACP Chat Agentic Cancel Stop', () => { + test.setTimeout(ACP_BDD_FIXTURE_HOOK_TIMEOUT_MS); + + test.beforeAll(async () => { + test.setTimeout(ACP_BDD_FIXTURE_HOOK_TIMEOUT_MS); + await loadLongStreamWorkbench(); + }); + + test.afterAll(async () => { + await runtime?.dispose(); + }); + + test('Cancel Stop returns the input to a usable state during the long-stream fixture', async ({ + browser: _browser, + }, testInfo) => { + const evidence = createBddEvidence(testInfo, 'acp-chat-agentic-cancel-stop', { + sourceScenario: 'test/bdd/acp-chat-agentic-cancel-stop.scenario.md', + profile: 'interactive', + executionMode: 'deterministic-fixture', + hardeningVerdict: 'CONVERT', + }); + + await sendPrompt(LONG_STREAM_PROMPT); + + await expect(chatSlot().locator('.rce-user-msg')).toHaveCount(1, { timeout: 30_000 }); + await expect(chatSlot().getByText(ACTIVE_STREAM_SENTINEL)).toBeVisible({ timeout: 30_000 }); + await expect(chatButton('Stop')).toBeVisible(); + + const activeProof = await evidence.saveJson( + '01-active-stream', + { + userRows: await chatSlot().locator('.rce-user-msg').count(), + assistantRows: await chatSlot().locator('.rce-ai-msg').count(), + hasActiveSentinel: await chatSlot().getByText(ACTIVE_STREAM_SENTINEL).isVisible(), + stopVisible: await chatButton('Stop').isVisible(), + }, + 'long-stream request shows active content and a stop affordance', + ); + + await chatButton('Stop').click(); + await expect(chatButton('Send')).toBeVisible({ timeout: 30_000 }); + await expect(chatButton('Stop')).toBeHidden(); + + const input = chatInput(); + await input.click(); + await page.keyboard.type(POST_CANCEL_DRAFT); + await expect(input).toContainText(POST_CANCEL_DRAFT); + + const stoppedProof = await evidence.saveJson( + '02-stopped-input-usable', + { + sendVisible: await chatButton('Send').isVisible(), + stopVisible: await chatButton('Stop') + .isVisible() + .catch(() => false), + inputText: await input.textContent(), + }, + 'stopping the long stream restores the send affordance and editable input', + ); + + evidence.recordCriticalPoint({ + id: 'CP1', + requirement: 'The long-stream fixture visibly enters active streaming state.', + status: 'pass', + evidence: [activeProof].filter(Boolean) as string[], + }); + evidence.recordCriticalPoint({ + id: 'CP2', + requirement: 'A user-facing stop control is visible while the stream is active.', + status: 'pass', + evidence: [activeProof].filter(Boolean) as string[], + }); + evidence.recordCriticalPoint({ + id: 'CP3', + requirement: 'Stopping the stream returns the Agentic input to a usable state.', + status: 'pass', + evidence: [stoppedProof].filter(Boolean) as string[], + }); + + await evidence.finalize({ + scenarioVerdict: 'PASS', + hardeningVerdict: 'CONVERT', + runtime: { + url: page.url(), + viewport: page.viewportSize(), + browserSurface: 'Playwright Chromium', + fixture: runtime.fixture, + profile: runtime.profile, + }, + }); + }); +}); diff --git a/tools/playwright/src/tests/acp-chat-agentic-config-controls.test.ts b/tools/playwright/src/tests/acp-chat-agentic-config-controls.test.ts new file mode 100644 index 0000000000..052943d60d --- /dev/null +++ b/tools/playwright/src/tests/acp-chat-agentic-config-controls.test.ts @@ -0,0 +1,380 @@ +// Source: test/bdd/acp-chat-agentic-config-controls.scenario.md + +import { type Locator, expect } from '@playwright/test'; + +import test, { page } from './hooks'; +import { + ACP_BDD_FIXTURE_HOOK_TIMEOUT_MS, + type AcpBddFixtureRuntime, + loadAcpBddFixtureWorkbench, +} from './utils/acp-bdd-fixture'; +import { createBddEvidence } from './utils/bdd-evidence'; + +const CONFIG_SELECTOR = '[role="combobox"][class*="config_selector"]'; + +let runtime: AcpBddFixtureRuntime; + +interface ConfigProof { + configId: string; + value: string | boolean; + sessionId?: string; + hasResponse: boolean; + responseConfigOptionCount: number; + responseCurrentValues: Array<{ + id: string; + category?: string; + currentValue: string | boolean; + }>; +} + +interface PromptConfigSnapshotProof { + hasSnapshotText: boolean; + hasAssistantCompletion: boolean; + snapshots: Array<{ + mode?: string; + model?: string; + thought?: string; + webSearch?: boolean; + }>; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +async function loadFullProfileWorkbench() { + runtime = await loadAcpBddFixtureWorkbench(page, { + fixture: 'stream-rich', + profile: 'full', + delayMs: 5, + showChatView: true, + ensureAgenticLayout: true, + viewport: { width: 1800, height: 1000 }, + }); + await expect(page.getByRole('heading', { name: 'AI Assistant' })).toBeVisible(); +} + +function configSelectors(): Locator { + return page.locator(CONFIG_SELECTOR); +} + +async function readFooterConfigValues(): Promise { + return (await configSelectors().allTextContents()).map((value) => value.replace(/\s+/g, ' ').trim()); +} + +async function selectFooterConfig(comboIndex: number, label: string) { + const combo = configSelectors().nth(comboIndex); + await expect(combo).toBeVisible(); + await combo.click(); + + const option = page + .locator('[role="option"]') + .filter({ + has: page.locator('[class*="option_label"]', { + hasText: new RegExp(`^${escapeRegExp(label)}$`), + }), + }) + .first(); + await expect(option).toBeVisible(); + await option.click(); + await expect(combo).toContainText(label); +} + +async function openAndClearAcpDebugLog() { + await runtime.app.quickCommandPalette.type('Open ACP Debug Log'); + await expect(page.getByText('Open ACP Debug Log', { exact: true })).toBeVisible(); + await page.keyboard.press('Enter'); + await expect(page.getByRole('heading', { name: 'ACP Debug Log' })).toBeVisible(); + + await page.getByRole('button', { name: 'Clear' }).click(); + await expect(page.getByText('No ACP debug log entries yet.')).toBeVisible(); +} + +async function readSetConfigProof(): Promise { + return page.evaluate(() => { + const text = document.body.innerText || ''; + const jsonLines = text + .split(/\n+/) + .map((line) => line.trim()) + .filter((line) => line.startsWith('{"jsonrpc"')); + const messages: any[] = []; + for (const line of jsonLines) { + try { + messages.push(JSON.parse(line)); + } catch (_error) { + // Ignore pretty-printed or partial log lines. + } + } + + const requests = messages.filter((message) => message.method === 'session/set_config_option'); + const responsesById = new Map( + messages + .filter((message) => message.id !== undefined && message.result && Array.isArray(message.result.configOptions)) + .map((message) => [String(message.id), message]), + ); + + return requests.map((request) => { + const response = responsesById.get(String(request.id)); + const options = response?.result?.configOptions || []; + return { + configId: request.params?.configId, + value: request.params?.value, + sessionId: request.params?.sessionId, + hasResponse: !!response, + responseConfigOptionCount: options.length, + responseCurrentValues: options.map((option: any) => ({ + id: option.id, + category: option.category, + currentValue: option.currentValue, + })), + }; + }); + }); +} + +async function waitForSetConfigProofValues() { + await page.waitForFunction(() => { + const compactLog = (document.body.innerText || '').replace(/\s+/g, ''); + return ( + compactLog.includes('"method":"session/set_config_option"') && + compactLog.includes('"configId":"bdd-mode","value":"chat"') && + compactLog.includes('"configId":"bdd-model","value":"bdd-large"') && + compactLog.includes('"configId":"bdd-thought-level","value":"high"') && + compactLog.includes('"configId":"bdd-web-search","value":true') && + compactLog.includes('"id":"bdd-mode"') && + compactLog.includes('"category":"mode"') && + compactLog.includes('"currentValue":"chat"') && + compactLog.includes('"id":"bdd-model"') && + compactLog.includes('"category":"model"') && + compactLog.includes('"currentValue":"bdd-large"') && + compactLog.includes('"id":"bdd-thought-level"') && + compactLog.includes('"category":"thought_level"') && + compactLog.includes('"currentValue":"high"') && + compactLog.includes('"id":"bdd-web-search"') && + compactLog.includes('"category":"_bdd_feature"') && + compactLog.includes('"currentValue":true') + ); + }); +} + +function expectProofValue(proof: ConfigProof[], configId: string, value: string | boolean, category?: string) { + const item = proof.find((entry) => entry.configId === configId && entry.value === value); + expect(item, `missing set_config_option proof for ${configId}=${value}`).toBeDefined(); + expect(item?.sessionId).toMatch(/^bdd-session-/); + expect(item?.hasResponse).toBe(true); + expect(item?.responseConfigOptionCount).toBeGreaterThanOrEqual(4); + + const returnedOption = item?.responseCurrentValues.find((option) => option.id === configId); + expect(returnedOption).toMatchObject({ + id: configId, + currentValue: value, + ...(category ? { category } : {}), + }); +} + +async function sendDeterministicPrompt() { + const input = page.locator('.AI-Chat-slot [contenteditable="true"]').last(); + await expect(input).toBeVisible(); + await input.click(); + await page.keyboard.type('BDD config controls snapshot'); + await page.getByRole('button', { name: 'Send' }).click(); + await expect(page.locator('.AI-Chat-slot').getByText('BDD_ASSISTANT_PART_2 completed.')).toBeVisible({ + timeout: 30_000, + }); + await expect(page.getByRole('button', { name: 'Send' })).toBeVisible({ timeout: 30_000 }); +} + +async function waitForPromptConfigSnapshot() { + await page.waitForFunction(() => { + const compactLog = (document.body.innerText || '').replace(/\s+/g, ''); + return ( + compactLog.includes('BDD_CONFIG_SNAPSHOTmode=chatmodel=bdd-largethought=highwebSearch=true') && + compactLog.includes('"configSnapshot":{"mode":"chat","model":"bdd-large","thought":"high","webSearch":true}') && + compactLog.includes('BDD_ASSISTANT_PART_2completed.') + ); + }); +} + +async function readPromptConfigSnapshotProof(): Promise { + return page.evaluate(() => { + const text = document.body.innerText || ''; + const jsonLines = text + .split(/\n+/) + .map((line) => line.trim()) + .filter((line) => line.startsWith('{"jsonrpc"')); + const messages: any[] = []; + for (const line of jsonLines) { + try { + messages.push(JSON.parse(line)); + } catch (_error) { + // Ignore pretty-printed or partial log lines. + } + } + + const snapshots = messages + .map((message) => message.params?.update?.rawInput?.configSnapshot) + .filter((snapshot) => snapshot && typeof snapshot === 'object'); + + return { + hasSnapshotText: text.includes('BDD_CONFIG_SNAPSHOT mode=chat model=bdd-large thought=high webSearch=true'), + hasAssistantCompletion: text.includes('BDD_ASSISTANT_PART_2 completed.'), + snapshots, + }; + }); +} + +async function restoreDefaultConfigValues() { + if ((await configSelectors().count()) < 4) { + return; + } + if ( + await page + .getByRole('button', { name: 'Stop' }) + .isVisible() + .catch(() => false) + ) { + await page.getByRole('button', { name: 'Stop' }).click(); + await expect(page.getByRole('button', { name: 'Send' })).toBeVisible({ timeout: 30_000 }); + } + await selectFooterConfig(0, 'Agent'); + await selectFooterConfig(1, 'BDD Small'); + await selectFooterConfig(2, 'Medium'); + await selectFooterConfig(3, 'Off'); +} + +test.describe('ACP Chat Agentic footer config controls', () => { + test.setTimeout(ACP_BDD_FIXTURE_HOOK_TIMEOUT_MS); + + test.beforeAll(async () => { + test.setTimeout(ACP_BDD_FIXTURE_HOOK_TIMEOUT_MS); + await loadFullProfileWorkbench(); + }); + + test.afterAll(async () => { + await runtime?.dispose(); + }); + + test('applies footer config options through ACP session config protocol', async ({ browser: _browser }, testInfo) => { + const evidence = createBddEvidence(testInfo, 'acp-chat-agentic-config-controls', { + sourceScenario: 'test/bdd/acp-chat-agentic-config-controls.scenario.md', + profile: 'full', + executionMode: 'deterministic-fixture', + hardeningVerdict: 'CONVERT', + }); + + await openAndClearAcpDebugLog(); + + await expect(configSelectors()).toHaveCount(4); + const initialFooterValues = await readFooterConfigValues(); + expect(initialFooterValues).toEqual(['Agent', 'BDD Small', 'Medium', 'Off']); + const initialFooterProof = await evidence.saveJson( + '01-initial-footer-config', + { values: initialFooterValues }, + 'initial ACP footer config values', + ); + + try { + await selectFooterConfig(0, 'Chat'); + await selectFooterConfig(1, 'BDD Large'); + await selectFooterConfig(2, 'High'); + await selectFooterConfig(3, 'On'); + const changedFooterValues = await readFooterConfigValues(); + expect(changedFooterValues).toEqual(['Chat', 'BDD Large', 'High', 'On']); + const changedFooterProof = await evidence.saveJson( + '02-changed-footer-config', + { values: changedFooterValues }, + 'changed ACP footer config values after UI selection', + ); + const changedFooterScreenshot = await evidence.captureScreenshot( + page, + '03-changed-footer-config', + 'footer config selectors after selection', + ); + + await waitForSetConfigProofValues(); + const proof = await readSetConfigProof(); + expectProofValue(proof, 'bdd-mode', 'chat', 'mode'); + expectProofValue(proof, 'bdd-model', 'bdd-large', 'model'); + expectProofValue(proof, 'bdd-thought-level', 'high', 'thought_level'); + expectProofValue(proof, 'bdd-web-search', true, '_bdd_feature'); + const setConfigProof = await evidence.saveJson( + '04-set-config-protocol-proof', + proof, + 'ACP session/set_config_option protocol proof', + ); + + await sendDeterministicPrompt(); + await waitForPromptConfigSnapshot(); + const promptProof = await readPromptConfigSnapshotProof(); + expect(promptProof).toMatchObject({ + hasSnapshotText: true, + hasAssistantCompletion: true, + }); + expect(promptProof.snapshots).toContainEqual({ + mode: 'chat', + model: 'bdd-large', + thought: 'high', + webSearch: true, + }); + const promptConfigProof = await evidence.saveJson( + '05-prompt-config-snapshot-proof', + promptProof, + 'prompt turn used the selected config option values', + ); + + expect(await readFooterConfigValues()).toEqual(['Chat', 'BDD Large', 'High', 'On']); + + evidence.recordCriticalPoint({ + id: 'CP1', + requirement: 'Footer renders exactly the deterministic ACP config option values in order.', + status: 'pass', + evidence: [initialFooterProof].filter(Boolean) as string[], + }); + evidence.recordCriticalPoint({ + id: 'CP2', + requirement: 'Changing mode/model/thought/web-search visibly updates footer controls.', + status: 'pass', + evidence: [changedFooterProof, changedFooterScreenshot].filter(Boolean) as string[], + }); + evidence.recordCriticalPoint({ + id: 'CP3', + requirement: 'Each visible config change sends session/set_config_option with exact configId and value.', + status: 'pass', + evidence: [setConfigProof].filter(Boolean) as string[], + }); + evidence.recordCriticalPoint({ + id: 'CP4', + requirement: 'The deterministic prompt turn receives the selected config snapshot.', + status: 'pass', + evidence: [promptConfigProof].filter(Boolean) as string[], + }); + } finally { + await restoreDefaultConfigValues(); + } + + const restoredFooterValues = await readFooterConfigValues(); + expect(restoredFooterValues).toEqual(['Agent', 'BDD Small', 'Medium', 'Off']); + const restoredFooterProof = await evidence.saveJson( + '06-restored-footer-config', + { values: restoredFooterValues }, + 'restored ACP footer config values', + ); + evidence.recordCriticalPoint({ + id: 'CP5', + requirement: 'Footer config values are restored after the deterministic fixture run.', + status: 'pass', + evidence: [restoredFooterProof].filter(Boolean) as string[], + }); + await evidence.finalize({ + scenarioVerdict: 'PASS', + hardeningVerdict: 'CONVERT', + runtime: { + url: page.url(), + viewport: page.viewportSize(), + browserSurface: 'Playwright Chromium', + fixture: 'stream-rich', + profile: 'full', + }, + }); + }); +}); diff --git a/tools/playwright/src/tests/acp-chat-agentic-deep-thinking-collapse.test.ts b/tools/playwright/src/tests/acp-chat-agentic-deep-thinking-collapse.test.ts new file mode 100644 index 0000000000..e365d7bc79 --- /dev/null +++ b/tools/playwright/src/tests/acp-chat-agentic-deep-thinking-collapse.test.ts @@ -0,0 +1,234 @@ +// Source: test/bdd/acp-chat-agentic-deep-thinking-collapse.scenario.md + +import { expect } from '@playwright/test'; + +import test, { page } from './hooks'; +import { + ACP_BDD_FIXTURE_HOOK_TIMEOUT_MS, + type AcpBddFixtureRuntime, + loadAcpBddFixtureWorkbench, +} from './utils/acp-bdd-fixture'; +import { createBddEvidence } from './utils/bdd-evidence'; + +const FIRST_REASONING_SENTINEL = 'BDD_THOUGHT_STEP_1'; +const SECOND_REASONING_SENTINEL = 'BDD_CONFIG_SNAPSHOT'; +const COMPLETION_SENTINEL = 'BDD_ASSISTANT_PART_2 completed.'; +const COLLAPSED_PROMPT = 'BDD deep thinking stays collapsed'; +const EXPANDED_PROMPT = 'BDD deep thinking expands while streaming'; + +let runtime: AcpBddFixtureRuntime; + +async function loadInteractiveStreamFixture() { + runtime = await loadAcpBddFixtureWorkbench(page, { + fixture: 'stream-rich', + profile: 'interactive', + delayMs: 80, + showChatView: true, + ensureAgenticLayout: true, + viewport: { width: 1800, height: 1000 }, + }); + await expect(page.getByRole('heading', { name: 'AI Assistant' })).toBeVisible(); +} + +function chatInput() { + return page.locator('.AI-Chat-slot [contenteditable="true"]').last(); +} + +function deepThinkingToggles() { + return page.getByRole('button', { name: /Deep Thinking/ }); +} + +async function visibleTextSnapshot(afterText?: string) { + return page.evaluate((anchor) => { + const text = document.body.innerText || ''; + if (!anchor) { + return text; + } + const index = text.lastIndexOf(anchor); + return index === -1 ? text : text.slice(index); + }, afterText); +} + +async function sendPrompt(prompt: string, expectedCompletionCount: number) { + const input = chatInput(); + await expect(input).toBeVisible(); + await input.click(); + await page.keyboard.type(prompt); + await page.getByRole('button', { name: 'Send' }).click(); + await expect(deepThinkingToggles().last()).toBeVisible({ timeout: 30_000 }); + + await page.waitForFunction( + ({ completion, expectedCount }) => { + const text = document.body.innerText || ''; + return text.split(completion).length - 1 >= expectedCount; + }, + { completion: COMPLETION_SENTINEL, expectedCount: expectedCompletionCount }, + { timeout: 30_000 }, + ); + await expect(page.getByRole('button', { name: 'Send' })).toBeVisible({ timeout: 30_000 }); +} + +async function readAcpSessionState() { + return page.evaluate(async () => (navigator as any).modelContext.executeTool('acp_chat_get_session_state', {})); +} + +test.describe('ACP Chat Agentic Deep Thinking collapse', () => { + test.setTimeout(ACP_BDD_FIXTURE_HOOK_TIMEOUT_MS); + + test.beforeAll(async () => { + test.setTimeout(ACP_BDD_FIXTURE_HOOK_TIMEOUT_MS); + await loadInteractiveStreamFixture(); + }); + + test.afterAll(async () => { + await runtime?.dispose(); + }); + + test('keeps Deep Thinking collapsed by default and expandable during streaming', async ({ + browser: _browser, + }, testInfo) => { + const evidence = createBddEvidence(testInfo, 'acp-chat-agentic-deep-thinking-collapse', { + sourceScenario: 'test/bdd/acp-chat-agentic-deep-thinking-collapse.scenario.md', + profile: 'interactive', + executionMode: 'deterministic-fixture', + hardeningVerdict: 'CONVERT', + }); + + await sendPrompt(COLLAPSED_PROMPT, 1); + + const collapsedAfterCompletion = await visibleTextSnapshot(COLLAPSED_PROMPT); + expect(collapsedAfterCompletion).toContain('Deep Thinking'); + expect(collapsedAfterCompletion).not.toContain(FIRST_REASONING_SENTINEL); + expect(collapsedAfterCompletion).not.toContain(SECOND_REASONING_SENTINEL); + const collapsedProof = await evidence.saveJson( + '01-collapsed-after-completion', + { + hasDeepThinking: collapsedAfterCompletion.includes('Deep Thinking'), + hasFirstReasoningSentinel: collapsedAfterCompletion.includes(FIRST_REASONING_SENTINEL), + hasSecondReasoningSentinel: collapsedAfterCompletion.includes(SECOND_REASONING_SENTINEL), + }, + 'completed response keeps Deep Thinking content collapsed by default', + ); + + const completedToggleCount = await deepThinkingToggles().count(); + const input = chatInput(); + await expect(input).toBeVisible(); + await input.click(); + await page.keyboard.type(EXPANDED_PROMPT); + await page.getByRole('button', { name: 'Send' }).click(); + + await expect.poll(() => deepThinkingToggles().count(), { timeout: 30_000 }).toBeGreaterThan(completedToggleCount); + const activeToggle = deepThinkingToggles().nth(completedToggleCount); + await expect(activeToggle).toBeVisible({ timeout: 30_000 }); + + const collapsedWhileStreaming = await visibleTextSnapshot(EXPANDED_PROMPT); + expect(collapsedWhileStreaming).not.toContain(FIRST_REASONING_SENTINEL); + expect(collapsedWhileStreaming).not.toContain(SECOND_REASONING_SENTINEL); + + await activeToggle.click(); + await expect(page.getByText(FIRST_REASONING_SENTINEL)).toBeVisible({ timeout: 10_000 }); + await expect(page.getByText(SECOND_REASONING_SENTINEL)).toBeVisible({ timeout: 10_000 }); + + await page.waitForFunction( + ({ completion }) => { + const text = document.body.innerText || ''; + return text.split(completion).length - 1 >= 2; + }, + { completion: COMPLETION_SENTINEL }, + { timeout: 30_000 }, + ); + + // After stream completion, the Deep Thinking section may re-collapse briefly during re-render. + // Use expect.poll() to retry the snapshot check until sentinels are visible. + await expect + .poll( + async () => { + const expandedAfterStream = await visibleTextSnapshot(EXPANDED_PROMPT); + return { + hasFirst: expandedAfterStream.includes(FIRST_REASONING_SENTINEL), + hasSecond: expandedAfterStream.includes(SECOND_REASONING_SENTINEL), + }; + }, + { timeout: 10_000 }, + ) + .toEqual({ hasFirst: true, hasSecond: true }); + + const expandedAfterStream = await visibleTextSnapshot(EXPANDED_PROMPT); + expect(expandedAfterStream).toContain(FIRST_REASONING_SENTINEL); + expect(expandedAfterStream).toContain(SECOND_REASONING_SENTINEL); + const expandedProof = await evidence.saveJson( + '02-expanded-during-stream', + { + hasFirstReasoningSentinel: expandedAfterStream.includes(FIRST_REASONING_SENTINEL), + hasSecondReasoningSentinel: expandedAfterStream.includes(SECOND_REASONING_SENTINEL), + deepThinkingToggleCount: await deepThinkingToggles().count(), + }, + 'streaming Deep Thinking expands and remains associated with the assistant response', + ); + + await activeToggle.click(); + const recollapsedAfterClick = await visibleTextSnapshot(EXPANDED_PROMPT); + expect(recollapsedAfterClick).not.toContain(FIRST_REASONING_SENTINEL); + expect(recollapsedAfterClick).not.toContain(SECOND_REASONING_SENTINEL); + const recollapsedProof = await evidence.saveJson( + '03-recollapsed-after-click', + { + hasFirstReasoningSentinel: recollapsedAfterClick.includes(FIRST_REASONING_SENTINEL), + hasSecondReasoningSentinel: recollapsedAfterClick.includes(SECOND_REASONING_SENTINEL), + }, + 'clicking Deep Thinking again hides reasoning sentinel text', + ); + + const sessionState = await readAcpSessionState(); + const serializedState = JSON.stringify(sessionState); + expect(sessionState.success).toBe(true); + expect(serializedState).not.toContain(FIRST_REASONING_SENTINEL); + expect(serializedState).not.toContain(SECOND_REASONING_SENTINEL); + const stateProof = await evidence.saveJson( + '04-state-tool-metadata-only', + { + success: sessionState.success, + hasFirstReasoningSentinel: serializedState.includes(FIRST_REASONING_SENTINEL), + hasSecondReasoningSentinel: serializedState.includes(SECOND_REASONING_SENTINEL), + }, + 'ACP session state remains metadata-only after Deep Thinking interaction', + ); + + evidence.recordCriticalPoint({ + id: 'CP1', + requirement: 'Completed ACP Agentic Deep Thinking content is collapsed by default.', + status: 'pass', + evidence: [collapsedProof].filter(Boolean) as string[], + }); + evidence.recordCriticalPoint({ + id: 'CP2', + requirement: + 'Streaming ACP Agentic Deep Thinking can be expanded and preserves visible reasoning through completion.', + status: 'pass', + evidence: [expandedProof].filter(Boolean) as string[], + }); + evidence.recordCriticalPoint({ + id: 'CP3', + requirement: 'The same Deep Thinking toggle can collapse expanded reasoning again.', + status: 'pass', + evidence: [recollapsedProof].filter(Boolean) as string[], + }); + evidence.recordCriticalPoint({ + id: 'CP4', + requirement: 'ACP Chat session state does not expose reasoning sentinel content.', + status: 'pass', + evidence: [stateProof].filter(Boolean) as string[], + }); + await evidence.finalize({ + scenarioVerdict: 'PASS', + hardeningVerdict: 'CONVERT', + runtime: { + url: page.url(), + viewport: page.viewportSize(), + browserSurface: 'Playwright Chromium', + fixture: 'stream-rich', + profile: 'interactive', + }, + }); + }); +}); diff --git a/tools/playwright/src/tests/acp-chat-agentic-error-taxonomy.test.ts b/tools/playwright/src/tests/acp-chat-agentic-error-taxonomy.test.ts new file mode 100644 index 0000000000..807da3dcbf --- /dev/null +++ b/tools/playwright/src/tests/acp-chat-agentic-error-taxonomy.test.ts @@ -0,0 +1,487 @@ +// Source: test/bdd/acp-chat-agentic-error-taxonomy.scenario.md +// Source: test/bdd/acp-chat-agentic-input-send.scenario.md +// Source: test/bdd/acp-error-and-recovery.scenario.md + +import { type Locator, expect } from '@playwright/test'; + +import test, { page } from './hooks'; +import { + ACP_BDD_FIXTURE_HOOK_TIMEOUT_MS, + type AcpBddFixture, + type AcpBddFixtureOptions, + type AcpBddFixtureRuntime, + ensureAgenticLayout, + loadAcpBddFixtureWorkbench, + waitForAcpChatReady, + waitForWorkbenchReady, +} from './utils/acp-bdd-fixture'; + +const FAILURE_TEST_TIMEOUT_MS = ACP_BDD_FIXTURE_HOOK_TIMEOUT_MS * 2; +const CONFIG_SELECTOR = '[role="combobox"][class*="config_selector"]'; +const STREAM_RECOVERY_PROMPT = 'BDD recovery smoke'; +const LOAD_FAILURE_SESSION_PREFIX = 'bdd-load-failure-history'; +const LOAD_FAILURE_SESSION_IDS = [ + `acp:${LOAD_FAILURE_SESSION_PREFIX}-alpha`, + `acp:${LOAD_FAILURE_SESSION_PREFIX}-beta`, +]; +const DISCONNECTED_AGENT_ERROR_PATTERN = + /agent.*(disconnect|closed|exit|stopped|terminated)|disconnect|connection.*closed|closed.*connection|process.*exit|process.*stopped|transport.*closed|stream.*closed|channel.*closed|terminated/i; + +interface FailureUiSnapshot { + chatText: string; + notificationText: string; + errorNotificationCount: number; + infoNotificationCount: number; + chatErrorCount: number; + userRowCount: number; + assistantRowCount: number; + hasStackTrace: boolean; + hasRawRpcPayload: boolean; + hasSecretLikeText: boolean; +} + +interface AcpSessionSummary { + sessionId: string; + rawSessionId?: string; + title: string; +} + +interface HistoryRowProof { + id: string; + title: string; +} + +async function withFixture( + fixture: AcpBddFixture, + run: (runtime: AcpBddFixtureRuntime) => Promise, + options: Partial> = {}, +) { + const runtime = await loadAcpBddFixtureWorkbench(page, { + fixture, + profile: 'interactive', + delayMs: 20, + showChatView: true, + ensureAgenticLayout: true, + viewport: { width: 1800, height: 1000 }, + ...options, + }); + + try { + await expect(page.getByRole('heading', { name: 'AI Assistant' })).toBeVisible(); + return await run(runtime); + } finally { + await runtime.dispose(); + } +} + +function chatSlot(): Locator { + return page.locator('.AI-Chat-slot'); +} + +function chatInput(): Locator { + return chatSlot().locator('[contenteditable="true"]').last(); +} + +function sendButton(): Locator { + return chatSlot().getByRole('button', { name: 'Send' }).last(); +} + +function recoveryButton(): Locator { + return chatSlot() + .getByRole('button', { name: /Afresh|Regenerate|Retry|重新生成/i }) + .last(); +} + +function configSelectors(): Locator { + return page.locator(CONFIG_SELECTOR); +} + +async function sendPrompt(prompt: string) { + await expect(chatInput()).toBeVisible(); + await chatInput().click(); + await page.keyboard.type(prompt); + await expect(sendButton()).toBeVisible(); + await sendButton().click(); +} + +async function readSessionState() { + return page.evaluate(async () => (navigator as any).modelContext.executeTool('acp_chat_get_session_state', {})); +} + +async function executeAcpTool(name: string, args: Record = {}) { + return page.evaluate( + async ({ toolName, toolArgs }) => (navigator as any).modelContext.executeTool(toolName, toolArgs), + { toolName: name, toolArgs: args }, + ) as Promise<{ success: boolean; result: T }>; +} + +async function listSessions(): Promise { + const result = await executeAcpTool<{ sessions: AcpSessionSummary[]; total: number }>('acp_chat_list_sessions'); + expect(result.success).toBe(true); + return result.result.sessions; +} + +async function readFailureUiSnapshot(): Promise { + return page.evaluate(() => { + const isVisible = (element: Element) => { + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + return rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden' && style.display !== 'none'; + }; + + const visibleText = (selector: string) => + Array.from(document.querySelectorAll(selector)) + .filter(isVisible) + .map((element) => element.textContent || '') + .join('\n'); + + const chatText = visibleText('.AI-Chat-slot'); + const notificationText = visibleText('.kt-notification-wrapper'); + const visibleTextToScan = `${chatText}\n${notificationText}`; + + return { + chatText, + notificationText, + errorNotificationCount: Array.from(document.querySelectorAll('.kt-notification-error')).filter(isVisible).length, + infoNotificationCount: Array.from(document.querySelectorAll('.kt-notification-info')).filter(isVisible).length, + chatErrorCount: Array.from(document.querySelectorAll('.AI-Chat-slot .rce-ai-msg [class*="error"]')).filter( + isVisible, + ).length, + userRowCount: Array.from(document.querySelectorAll('.AI-Chat-slot .rce-user-msg')).filter(isVisible).length, + assistantRowCount: Array.from(document.querySelectorAll('.AI-Chat-slot .rce-ai-msg')).filter(isVisible).length, + hasStackTrace: /\n\s*at\s+\S+\s+\(|\bat\s+\S+:\d+:\d+/.test(visibleTextToScan), + hasRawRpcPayload: /"jsonrpc"|rawInput|rawOutput|session\/prompt|session\/new|session\/load/i.test( + visibleTextToScan, + ), + hasSecretLikeText: /token=|api[_-]?key|password|sk-[a-z0-9]/i.test(visibleTextToScan), + }; + }); +} + +async function expectSafeVisibleFailure(snapshot: FailureUiSnapshot) { + expect(snapshot.hasStackTrace).toBe(false); + expect(snapshot.hasRawRpcPayload).toBe(false); + expect(snapshot.hasSecretLikeText).toBe(false); +} + +async function expectInputRecovered() { + await expect(sendButton()).toBeVisible({ timeout: 30_000 }); + await expect(chatInput()).toBeVisible(); + await expect(chatInput()).toBeEditable(); +} + +async function reloadFixtureWorkbench(runtime: AcpBddFixtureRuntime) { + await page.goto(runtime.url); + await waitForWorkbenchReady(page); + await page.waitForFunction(() => Boolean((navigator as any).modelContext?.executeTool)); + await page.evaluate(async () => { + await (navigator as any).modelContext.executeTool('acp_chat_show_chat_view', {}); + }); + await waitForAcpChatReady(page); + await ensureAgenticLayout(page); + await expect(page.getByRole('heading', { name: 'AI Assistant' })).toBeVisible(); +} + +async function ensureHistoryVisible() { + const inline = page.locator('[data-testid="acp-chat-history-inline"]'); + if (await inline.isVisible().catch(() => false)) { + return; + } + + const collapsed = page.locator('[data-testid="acp-chat-history-collapsed"]'); + if (await collapsed.isVisible().catch(() => false)) { + await page.getByLabel(/Expand Chat History|展开聊天历史/).click(); + await expect(inline).toBeVisible({ timeout: 30_000 }); + return; + } + + const popoverButton = page.locator('[data-testid="acp-chat-history-button"]'); + await expect(popoverButton).toBeVisible({ timeout: 30_000 }); + await popoverButton.click(); + await expect(page.locator('[data-testid="acp-chat-history-popover"]')).toBeVisible({ timeout: 30_000 }); +} + +async function readHistoryRows(): Promise { + await ensureHistoryVisible(); + return page.evaluate(() => { + const isVisible = (element: HTMLElement) => { + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + return rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden' && style.display !== 'none'; + }; + + return Array.from(document.querySelectorAll('[data-testid^="chat-history-item-"]')) + .filter(isVisible) + .map((element) => { + const id = element.getAttribute('data-testid')!.replace('chat-history-item-', ''); + const title = document.getElementById(`chat-history-item-title-${id}`)?.textContent?.trim() || ''; + return { id, title }; + }); + }); +} + +async function waitForVisibleSeededHistoryRows(): Promise { + await expect + .poll( + async () => { + const rows = await readHistoryRows(); + return rows + .filter((row) => LOAD_FAILURE_SESSION_IDS.includes(row.id)) + .map((row) => row.id) + .sort(); + }, + { timeout: 30_000 }, + ) + .toEqual([...LOAD_FAILURE_SESSION_IDS].sort()); + + return (await readHistoryRows()).filter((row) => LOAD_FAILURE_SESSION_IDS.includes(row.id)); +} + +async function expectStreamRichRecovery(label: string) { + await withFixture('stream-rich', async () => { + await sendPrompt(STREAM_RECOVERY_PROMPT); + + await expect + .poll( + async () => { + const state = await readSessionState(); + return { + success: state.success, + active: state.result?.active, + requestCount: state.result?.session?.requestCount ?? 0, + historyMessageCount: state.result?.session?.historyMessageCount ?? 0, + rawSessionIdHasAcpPrefix: String(state.result?.session?.rawSessionId || '').startsWith('acp:'), + }; + }, + { message: `stream-rich recovery did not settle after ${label}`, timeout: 30_000 }, + ) + .toMatchObject({ + success: true, + active: true, + requestCount: expect.any(Number), + rawSessionIdHasAcpPrefix: false, + }); + + await expect.poll(async () => (await readSessionState()).result?.session?.requestCount ?? 0).toBeGreaterThan(0); + await expectInputRecovered(); + + const snapshot = await readFailureUiSnapshot(); + expect(snapshot.chatErrorCount).toBe(0); + expect(snapshot.userRowCount).toBeGreaterThan(0); + expect(snapshot.assistantRowCount).toBeGreaterThan(0); + await expectSafeVisibleFailure(snapshot); + }); +} + +async function selectFooterConfig(comboIndex: number, label: string) { + const combo = configSelectors().nth(comboIndex); + await expect(combo).toBeVisible(); + await combo.click(); + const option = page + .locator('[role="option"]') + .filter({ hasText: new RegExp(`^\\s*${label}\\s*$`) }) + .first(); + await expect(option).toBeVisible(); + await option.click(); +} + +test.describe('ACP Chat Agentic Error Taxonomy and Recovery', () => { + test.setTimeout(FAILURE_TEST_TIMEOUT_MS); + + test('Input and Send Recovery: send failure preserves user row and exposes retry', async () => { + await withFixture('send-failure', async () => { + await sendPrompt('BDD visible recovery case A'); + + await expect + .poll(async () => (await readFailureUiSnapshot()).userRowCount, { timeout: 30_000 }) + .toBeGreaterThan(0); + await expect + .poll( + async () => { + const snapshot = await readFailureUiSnapshot(); + return snapshot.chatErrorCount > 0 && /send|failure|error/i.test(snapshot.chatText); + }, + { timeout: 30_000 }, + ) + .toBe(true); + + const snapshot = await readFailureUiSnapshot(); + expect(snapshot.chatErrorCount).toBeGreaterThan(0); + expect(snapshot.userRowCount).toBeGreaterThan(0); + await expect(recoveryButton()).toBeVisible(); + await expectInputRecovered(); + await expectSafeVisibleFailure(snapshot); + }); + + await expectStreamRichRecovery('send-failure'); + }); + + test('Error Taxonomy: create failure leaves the draft input recoverable', async () => { + await withFixture('create-failure', async () => { + await sendPrompt('BDD visible recovery case B'); + + await expect + .poll( + async () => { + const snapshot = await readFailureUiSnapshot(); + return { + errorNotificationCount: snapshot.errorNotificationCount, + hasCreateFailureCategory: /create|session|failure|error/i.test(snapshot.notificationText), + }; + }, + { timeout: 30_000 }, + ) + .toMatchObject({ errorNotificationCount: expect.any(Number), hasCreateFailureCategory: true }); + + const snapshot = await readFailureUiSnapshot(); + expect(snapshot.errorNotificationCount).toBeGreaterThan(0); + expect(snapshot.userRowCount).toBe(0); + await expectInputRecovered(); + expect(await readSessionState()).toMatchObject({ success: true, result: { active: false } }); + await expectSafeVisibleFailure(snapshot); + }); + + await expectStreamRichRecovery('create-failure'); + }); + + test('Recovery: load failure falls back to a usable draft from history selection', async () => { + await withFixture( + 'load-failure', + async (runtime) => { + await sendPrompt('BDD load failure history prewarm'); + await expect.poll(async () => (await readSessionState()).result?.session?.requestCount ?? 0).toBeGreaterThan(0); + await expectInputRecovered(); + + await reloadFixtureWorkbench(runtime); + await expect + .poll( + async () => + ( + await listSessions() + ) + .filter((session) => LOAD_FAILURE_SESSION_IDS.includes(session.sessionId)) + .map((session) => session.title) + .sort(), + { timeout: 30_000 }, + ) + .toEqual(['BDD History alpha', 'BDD History beta']); + + const rows = await waitForVisibleSeededHistoryRows(); + const historyItem = page.locator(`[data-testid="chat-history-item-${rows[0].id}"]`).first(); + await expect(historyItem).toBeVisible({ timeout: 30_000 }); + await historyItem.click(); + + await expect + .poll( + async () => { + const snapshot = await readFailureUiSnapshot(); + const state = await readSessionState(); + return { + hasRecoveryNotice: /history|new chat draft|session|not found|available/i.test( + snapshot.notificationText, + ), + infoNotificationCount: snapshot.infoNotificationCount, + active: state.result?.active, + }; + }, + { timeout: 30_000 }, + ) + .toMatchObject({ hasRecoveryNotice: true, active: false }); + + const snapshot = await readFailureUiSnapshot(); + expect(snapshot.infoNotificationCount).toBeGreaterThan(0); + await expectInputRecovered(); + await expectSafeVisibleFailure(snapshot); + }, + { sessionPrefix: LOAD_FAILURE_SESSION_PREFIX }, + ); + + await expectStreamRichRecovery('load-failure'); + }); + + test('Error Taxonomy: auth-required send failure is visible and retryable', async () => { + await withFixture('auth-required', async () => { + await sendPrompt('BDD visible recovery case C'); + + await expect + .poll( + async () => { + const snapshot = await readFailureUiSnapshot(); + return snapshot.chatErrorCount > 0 && /auth|required|sign.?in|login/i.test(snapshot.chatText); + }, + { timeout: 30_000 }, + ) + .toBe(true); + + const snapshot = await readFailureUiSnapshot(); + expect(snapshot.chatErrorCount).toBeGreaterThan(0); + expect(snapshot.userRowCount).toBeGreaterThan(0); + await expect(recoveryButton()).toBeVisible(); + await expectInputRecovered(); + await expectSafeVisibleFailure(snapshot); + }); + + await expectStreamRichRecovery('auth-required'); + }); + + test('Error Taxonomy: config failure keeps footer controls and input usable', async () => { + await withFixture('config-failure', async () => { + await expect.poll(async () => configSelectors().count(), { timeout: 30_000 }).toBeGreaterThanOrEqual(4); + await selectFooterConfig(0, 'Chat'); + + await expect + .poll( + async () => { + const snapshot = await readFailureUiSnapshot(); + return { + errorNotificationCount: snapshot.errorNotificationCount, + hasConfigCategory: /config|failure|error/i.test(snapshot.notificationText), + }; + }, + { timeout: 30_000 }, + ) + .toMatchObject({ errorNotificationCount: expect.any(Number), hasConfigCategory: true }); + + const snapshot = await readFailureUiSnapshot(); + expect(snapshot.errorNotificationCount).toBeGreaterThan(0); + await expect.poll(async () => configSelectors().count()).toBeGreaterThanOrEqual(4); + await expectInputRecovered(); + await expectSafeVisibleFailure(snapshot); + }); + + await expectStreamRichRecovery('config-failure'); + }); + + test('Error Taxonomy: disconnected agent recovery handles process exit', async () => { + await withFixture('process-exit', async () => { + await sendPrompt('BDD process exit recovery case D'); + + await expect + .poll( + async () => { + const snapshot = await readFailureUiSnapshot(); + const visibleFailureText = `${snapshot.chatText}\n${snapshot.notificationText}`; + return { + hasVisibleFailure: snapshot.chatErrorCount > 0 || snapshot.errorNotificationCount > 0, + hasDisconnectedCategory: DISCONNECTED_AGENT_ERROR_PATTERN.test(visibleFailureText), + hasUserRow: snapshot.userRowCount > 0, + }; + }, + { timeout: 30_000 }, + ) + .toMatchObject({ + hasVisibleFailure: true, + hasDisconnectedCategory: true, + hasUserRow: true, + }); + + const snapshot = await readFailureUiSnapshot(); + expect(snapshot.userRowCount).toBeGreaterThan(0); + expect(snapshot.chatErrorCount + snapshot.errorNotificationCount).toBeGreaterThan(0); + await expectInputRecovered(); + await expectSafeVisibleFailure(snapshot); + }); + + await expectStreamRichRecovery('process-exit'); + }); +}); diff --git a/tools/playwright/src/tests/acp-chat-agentic-fallback.test.ts b/tools/playwright/src/tests/acp-chat-agentic-fallback.test.ts new file mode 100644 index 0000000000..699e6578b7 --- /dev/null +++ b/tools/playwright/src/tests/acp-chat-agentic-fallback.test.ts @@ -0,0 +1,181 @@ +// Source: test/bdd/acp-chat-agentic-fallback.scenario.md + +import path from 'path'; + +import { expect } from '@playwright/test'; + +import { OpenSumiApp } from '../app'; +import { OpenSumiWorkspace } from '../workspace'; + +import test, { page } from './hooks'; +import { + aiNativeWorkbenchUrl, + ensureAgenticLayout, + waitForAcpChatReady, + waitForExplorerViewVisible, + waitForWorkbenchReady, + writeAiNativePanelLayoutSettings, +} from './utils/acp-bdd-fixture'; +import { createBddEvidence } from './utils/bdd-evidence'; + +const FORBIDDEN_ACP_TOOLS = [ + 'acp_sendMessage', + 'acp_createSession', + 'acp_switchSession', + 'acp_clearSession', + 'acp_cancelRequest', + 'acp_handlePermissionDialog', + 'acp_chat_getSessionState', + 'acp_chat_getPermissionState', + 'acp_chat_showChatView', +]; + +let app: OpenSumiApp; +let workspace: OpenSumiWorkspace; + +test.describe('ACP Chat Agentic fallback', () => { + test.beforeAll(async () => { + await page.setViewportSize({ width: 1800, height: 1000 }); + workspace = new OpenSumiWorkspace([path.resolve(__dirname, '../../src/tests/workspaces/default')]); + await workspace.initWorksapce(); + await writeAiNativePanelLayoutSettings(workspace.workspace.codeUri.fsPath, 'agentic'); + app = new OpenSumiApp(page); + await page.goto( + aiNativeWorkbenchUrl(workspace.workspace.codeUri.fsPath, 'default', 'agentic', { + forceAcpBackendReadyFailure: true, + }), + ); + await waitForWorkbenchReady(page); + }); + + test.afterAll(() => { + app.dispose(); + workspace.dispose(); + }); + + test('renders a usable local fallback surface when ACP backend readiness rejects', async ({ + browser: _browser, + }, testInfo) => { + const evidence = createBddEvidence(testInfo, 'acp-chat-agentic-fallback', { + sourceScenario: 'test/bdd/acp-chat-agentic-fallback.scenario.md', + profile: 'default', + executionMode: 'deterministic-fixture', + hardeningVerdict: 'CONVERT', + }); + + await page.waitForFunction(() => Boolean((navigator as any).modelContext?.executeTool)); + const showResult = await page.evaluate(async () => (navigator as any).modelContext.executeTool('acp_chat_show_chat_view', {})); + + await ensureAgenticLayout(page); + await waitForAcpChatReady(page); + await expect(page.locator('.AI-Chat-slot')).not.toContainText('Initializing ACP service'); + await expect(page.getByRole('heading', { name: 'AI Assistant' })).toBeVisible(); + await expect(page.locator('.AI-Chat-slot [contenteditable="true"]').last()).toBeVisible(); + await waitForExplorerViewVisible(page); + + const proof = await page.evaluate(async (forbiddenToolNames) => { + const isVisible = (element: Element) => { + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + return rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden' && style.display !== 'none'; + }; + const visibleText = Array.from(document.querySelectorAll('body *')) + .filter(isVisible) + .map((element) => element.textContent || '') + .join('\n'); + const slot = document.querySelector('.AI-Chat-slot'); + const input = slot?.querySelector('[contenteditable="true"]'); + const inputRect = input?.getBoundingClientRect(); + const modelContext = (navigator as any).modelContext; + const tools = await modelContext.getTools(); + const toolNames = tools.map((tool: { name: string }) => tool.name).sort(); + const sessionState = await modelContext.executeTool('acp_chat_get_session_state', {}); + const permissionState = await modelContext.executeTool('acp_chat_get_permission_state', {}); + + return { + acpTools: toolNames.filter((name: string) => name.startsWith('acp_chat')), + forbiddenTools: toolNames.filter( + (name: string) => forbiddenToolNames.includes(name) || name.startsWith('_opensumi/') || /[A-Z]/.test(name), + ), + inputVisible: Boolean(inputRect && inputRect.width > 0 && inputRect.height > 0), + loadingVisible: visibleText.includes('Initializing ACP service'), + fallbackSessionId: sessionState.result?.session?.sessionId, + fallbackRawSessionId: sessionState.result?.session?.rawSessionId, + sessionState, + permissionState, + safety: { + hasStackTrace: /\n\s*at\s+\S+\s+\(|\bat\s+\S+:\d+:\d+/.test(visibleText), + hasRawPayload: /"jsonrpc"|rawInput|rawOutput|session\/prompt|session\/new|session\/load/i.test(visibleText), + hasTokenLikeText: /\/mcp\/[^\s"']+|token=|api[_-]?key|authorization|password|sk-[a-z0-9]/i.test(visibleText), + }, + }; + }, FORBIDDEN_ACP_TOOLS); + const mergedProof = { ...proof, showResult }; + const stateProof = await evidence.saveJson( + '01-fallback-state-and-tools', + mergedProof, + 'fallback session state, tool surface, and visible safety scan', + ); + const screenshot = await evidence.captureScreenshot(page, '02-agentic-fallback', 'Agentic fallback chat surface'); + + expect(showResult).toMatchObject({ success: true, result: { shown: true } }); + expect(proof.acpTools).toEqual([ + 'acp_chat_get_permission_state', + 'acp_chat_get_session_state', + 'acp_chat_show_chat_view', + ]); + expect(proof.forbiddenTools).toEqual([]); + expect(proof.inputVisible).toBe(true); + expect(proof.loadingVisible).toBe(false); + expect(proof.sessionState.success).toBe(true); + expect(proof.sessionState.result.active).toBe(true); + expect(proof.fallbackSessionId).toBeTruthy(); + expect(String(proof.fallbackSessionId)).not.toMatch(/^acp:/); + expect(String(proof.fallbackRawSessionId)).not.toMatch(/^acp:/); + expect(proof.sessionState.result.session.messages).toBeUndefined(); + expect(proof.sessionState.result.session.content).toBeUndefined(); + expect(proof.sessionState.result.session.toolCallResults).toBeUndefined(); + expect(proof.permissionState.success).toBe(true); + expect(proof.permissionState.result).toEqual( + expect.objectContaining({ + activeDialogCount: expect.any(Number), + pendingCountExcludingActive: expect.any(Number), + }), + ); + expect(proof.safety).toEqual({ + hasStackTrace: false, + hasRawPayload: false, + hasTokenLikeText: false, + }); + + evidence.recordCriticalPoint({ + id: 'CP1', + requirement: 'Agentic AI Chat renders a usable surface instead of staying in ACP initialization.', + status: 'pass', + evidence: [stateProof, screenshot].filter(Boolean) as string[], + }); + evidence.recordCriticalPoint({ + id: 'CP2', + requirement: 'The fallback path creates a local non-ACP session and returns safe metadata-only state.', + status: 'pass', + evidence: [stateProof].filter(Boolean) as string[], + }); + evidence.recordCriticalPoint({ + id: 'CP3', + requirement: + 'Hidden mutation tools stay unavailable and visible output has no stack traces, raw payloads, or tokens.', + status: 'pass', + evidence: [stateProof].filter(Boolean) as string[], + }); + await evidence.finalize({ + scenarioVerdict: 'PASS', + hardeningVerdict: 'CONVERT', + runtime: { + url: page.url(), + viewport: page.viewportSize(), + browserSurface: 'Playwright Chromium', + fixture: 'local-loopback query acpBddBackendReadyFailure=reject', + }, + }); + }); +}); diff --git a/tools/playwright/src/tests/acp-chat-agentic-history.test.ts b/tools/playwright/src/tests/acp-chat-agentic-history.test.ts new file mode 100644 index 0000000000..716c9d8b7e --- /dev/null +++ b/tools/playwright/src/tests/acp-chat-agentic-history.test.ts @@ -0,0 +1,342 @@ +// Source: test/bdd/acp-chat-agentic-history.scenario.md + +import { expect } from '@playwright/test'; + +import test, { page } from './hooks'; +import { + ACP_BDD_FIXTURE_HOOK_TIMEOUT_MS, + type AcpBddFixtureRuntime, + loadAcpBddFixtureWorkbench, +} from './utils/acp-bdd-fixture'; +import { createBddEvidence } from './utils/bdd-evidence'; + +const SESSION_PREFIX = 'bdd-history-seeded'; +const SEEDED_RAW_SESSION_IDS = [`${SESSION_PREFIX}-alpha`, `${SESSION_PREFIX}-beta`]; +const SEEDED_SESSION_IDS = SEEDED_RAW_SESSION_IDS.map((id) => `acp:${id}`); +const METADATA_LEAK_SENTINELS = [ + 'BDD_ASSISTANT_PART', + 'BDD_THOUGHT_STEP', + 'BDD_TOOL_RESULT', + 'BDD_USER_TURN', + 'BDD_HISTORY_USER', + 'BDD_HISTORY_THOUGHT', + 'BDD_HISTORY_ASSISTANT', + 'BDD_HISTORY_TOOL_RESULT', +]; + +let runtime: AcpBddFixtureRuntime; + +interface AcpSessionSummary { + sessionId: string; + rawSessionId?: string; + title: string; + createdAt: number; + requestCount: number; + historyMessageCount: number; + slicedMessageCount: number; + threadStatus?: string; + hasPendingPermission?: boolean; +} + +interface HistoryRowProof { + id: string; + title: string; + selected: boolean; +} + +async function loadHistoryWorkbench() { + runtime = await loadAcpBddFixtureWorkbench(page, { + fixture: 'history', + profile: 'interactive', + delayMs: 10, + sessionPrefix: SESSION_PREFIX, + showChatView: true, + ensureAgenticLayout: true, + viewport: { width: 1600, height: 900 }, + }); + await expect(page.getByRole('heading', { name: 'AI Assistant' })).toBeVisible(); + await expect + .poll( + () => + page.evaluate(async () => { + const tools = await (navigator as any).modelContext.getTools(); + return tools.map((tool: { name: string }) => tool.name); + }), + { timeout: 30_000 }, + ) + .toContain('acp_chat_list_sessions'); +} + +async function executeAcpTool(name: string, args: Record = {}) { + return page.evaluate( + async ({ toolName, toolArgs }) => (navigator as any).modelContext.executeTool(toolName, toolArgs), + { toolName: name, toolArgs: args }, + ) as Promise<{ success: boolean; result: T }>; +} + +async function listSessions(): Promise { + const result = await executeAcpTool<{ sessions: AcpSessionSummary[]; total: number }>('acp_chat_list_sessions'); + expect(result.success).toBe(true); + return result.result.sessions; +} + +async function getSessionState() { + const result = await executeAcpTool<{ active: boolean; session: AcpSessionSummary | null }>( + 'acp_chat_get_session_state', + ); + expect(result.success).toBe(true); + return result.result; +} + +async function waitForSeededSessions(): Promise { + await expect + .poll( + async () => { + const sessions = await listSessions(); + return sessions + .map((session) => session.rawSessionId) + .filter((id): id is string => !!id && SEEDED_RAW_SESSION_IDS.includes(id)) + .sort(); + }, + { timeout: 30_000 }, + ) + .toEqual([...SEEDED_RAW_SESSION_IDS].sort()); + + return (await listSessions()).filter((session) => SEEDED_SESSION_IDS.includes(session.sessionId)); +} + +async function ensureHistoryVisible() { + const inline = page.locator('[data-testid="acp-chat-history-inline"]'); + if (await inline.isVisible().catch(() => false)) { + return; + } + + const collapsed = page.locator('[data-testid="acp-chat-history-collapsed"]'); + if (await collapsed.isVisible().catch(() => false)) { + await page.getByLabel(/Expand Chat History|展开聊天历史/).click(); + await expect(inline).toBeVisible({ timeout: 30_000 }); + return; + } + + const popoverButton = page.locator('[data-testid="acp-chat-history-button"]'); + await expect(popoverButton).toBeVisible({ timeout: 30_000 }); + await popoverButton.click(); + await expect(page.locator('[data-testid="acp-chat-history-popover"]')).toBeVisible({ timeout: 30_000 }); +} + +async function readHistoryRows(): Promise { + await ensureHistoryVisible(); + return page.evaluate(() => { + const isVisible = (element: HTMLElement) => { + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + return rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden' && style.display !== 'none'; + }; + + return Array.from(document.querySelectorAll('[data-testid^="chat-history-item-"]')) + .filter(isVisible) + .map((element) => { + const id = element.getAttribute('data-testid')!.replace('chat-history-item-', ''); + const title = document.getElementById(`chat-history-item-title-${id}`)?.textContent?.trim() || ''; + return { + id, + title, + selected: String(element.className).includes('selected'), + }; + }); + }); +} + +async function clickHistoryItem(sessionId: string) { + await ensureHistoryVisible(); + const row = page.locator(`[data-testid="chat-history-item-${sessionId}"]`).first(); + await expect(row).toBeVisible({ timeout: 30_000 }); + await row.click(); + await expect + .poll( + async () => { + const state = await getSessionState(); + return state.session?.sessionId; + }, + { timeout: 30_000 }, + ) + .toBe(sessionId); +} + +async function clickNewChat() { + await ensureHistoryVisible(); + await page + .getByLabel(/New Chat|新建聊天/) + .first() + .click(); +} + +function expectMetadataOnly(value: unknown) { + const serialized = JSON.stringify(value); + for (const sentinel of METADATA_LEAK_SENTINELS) { + expect(serialized).not.toContain(sentinel); + } +} + +test.describe('ACP Chat Agentic History', () => { + test.setTimeout(ACP_BDD_FIXTURE_HOOK_TIMEOUT_MS); + + test.beforeAll(async () => { + test.setTimeout(ACP_BDD_FIXTURE_HOOK_TIMEOUT_MS); + await loadHistoryWorkbench(); + }); + + test.afterAll(async () => { + await runtime?.dispose(); + }); + + test('History lists seeded sessions and switches selection through metadata-only state', async ({ + browser: _browser, + }, testInfo) => { + const evidence = createBddEvidence(testInfo, 'acp-chat-agentic-history', { + sourceScenario: 'test/bdd/acp-chat-agentic-history.scenario.md', + profile: 'interactive', + executionMode: 'deterministic-fixture', + hardeningVerdict: 'CONVERT', + }); + + const seededSessions = await waitForSeededSessions(); + const listProof = await evidence.saveJson( + '01-list-sessions-seeded', + { seededSessions }, + 'history fixture sessions returned through acp_chat_list_sessions', + ); + + expect(seededSessions).toHaveLength(2); + expect(seededSessions.map((session) => session.rawSessionId).sort()).toEqual([...SEEDED_RAW_SESSION_IDS].sort()); + expect(seededSessions.map((session) => session.title).sort()).toEqual(['BDD History alpha', 'BDD History beta']); + seededSessions.forEach((session) => { + expect(Object.keys(session).sort()).toEqual( + expect.arrayContaining([ + 'createdAt', + 'hasPendingPermission', + 'historyMessageCount', + 'rawSessionId', + 'requestCount', + 'sessionId', + 'slicedMessageCount', + 'threadStatus', + 'title', + ]), + ); + }); + expectMetadataOnly(seededSessions); + + const rows = await readHistoryRows(); + const seededRows = rows.filter((row) => SEEDED_SESSION_IDS.includes(row.id)); + const rowProof = await evidence.saveJson( + '02-visible-history-rows', + { rows }, + 'visible Agentic history rows for deterministic sessions', + ); + expect(seededRows.map((row) => row.id).sort()).toEqual([...SEEDED_SESSION_IDS].sort()); + expect(seededRows.map((row) => row.title).sort()).toEqual(['BDD History alpha', 'BDD History beta']); + + const expectedVisibleOrder = seededSessions.map((session) => session.sessionId); + expect(rows.map((row) => row.id).filter((id) => SEEDED_SESSION_IDS.includes(id))).toEqual(expectedVisibleOrder); + + const [newerSession, olderSession] = seededSessions; + await clickHistoryItem(olderSession.sessionId); + let state = await getSessionState(); + expect(state).toMatchObject({ + active: true, + session: { + sessionId: olderSession.sessionId, + rawSessionId: olderSession.rawSessionId, + title: olderSession.title, + }, + }); + + await clickHistoryItem(newerSession.sessionId); + state = await getSessionState(); + expect(state).toMatchObject({ + active: true, + session: { + sessionId: newerSession.sessionId, + rawSessionId: newerSession.rawSessionId, + title: newerSession.title, + }, + }); + + const switchedRows = await readHistoryRows(); + const selectedSeededRows = switchedRows.filter((row) => row.selected && SEEDED_SESSION_IDS.includes(row.id)); + expect(selectedSeededRows).toHaveLength(1); + expect(selectedSeededRows[0].id).toBe(newerSession.sessionId); + + await clickNewChat(); + await expect + .poll( + async () => { + const nextState = await getSessionState(); + return nextState.active; + }, + { timeout: 30_000 }, + ) + .toBe(false); + + const sessionsAfterNewChat = await listSessions(); + const seededAfterNewChat = sessionsAfterNewChat.filter((session) => SEEDED_SESSION_IDS.includes(session.sessionId)); + const rowsAfterNewChat = await readHistoryRows(); + const draftProof = await evidence.saveJson( + '03-new-chat-draft', + { + active: (await getSessionState()).active, + seededAfterNewChat, + visibleRows: rowsAfterNewChat, + }, + 'New Chat enters draft state without duplicating persisted empty history rows', + ); + + expect(seededAfterNewChat.map((session) => session.sessionId).sort()).toEqual([...SEEDED_SESSION_IDS].sort()); + expect( + rowsAfterNewChat + .map((row) => row.id) + .filter((id) => SEEDED_SESSION_IDS.includes(id)) + .sort(), + ).toEqual([...SEEDED_SESSION_IDS].sort()); + expect(rowsAfterNewChat.some((row) => row.title === 'New Session' || row.title === '(untitled)')).toBe(false); + expectMetadataOnly({ sessionsAfterNewChat, stateAfterNewChat: await getSessionState() }); + + evidence.recordCriticalPoint({ + id: 'CP1', + requirement: 'The history fixture exposes seeded sessions through acp_chat_list_sessions.', + status: 'pass', + evidence: [listProof].filter(Boolean) as string[], + }); + evidence.recordCriticalPoint({ + id: 'CP2', + requirement: 'Agentic history shows the deterministic seeded session ids and safe titles in session-list order.', + status: 'pass', + evidence: [rowProof].filter(Boolean) as string[], + }); + evidence.recordCriticalPoint({ + id: 'CP3', + requirement: 'Switching history items updates selected UI row and acp_chat_get_session_state.', + status: 'pass', + evidence: [rowProof].filter(Boolean) as string[], + }); + evidence.recordCriticalPoint({ + id: 'CP4', + requirement: 'New Chat enters draft state without creating duplicate empty history rows.', + status: 'pass', + evidence: [draftProof].filter(Boolean) as string[], + }); + + await evidence.finalize({ + scenarioVerdict: 'PASS', + hardeningVerdict: 'CONVERT', + runtime: { + url: page.url(), + viewport: page.viewportSize(), + browserSurface: 'Playwright Chromium', + fixture: runtime.fixture, + profile: runtime.profile, + }, + }); + }); +}); diff --git a/tools/playwright/src/tests/acp-chat-agentic-layout-stress.test.ts b/tools/playwright/src/tests/acp-chat-agentic-layout-stress.test.ts new file mode 100644 index 0000000000..e2a1f574ee --- /dev/null +++ b/tools/playwright/src/tests/acp-chat-agentic-layout-stress.test.ts @@ -0,0 +1,221 @@ +// Source: test/bdd/acp-chat-agentic-layout-stress.scenario.md + +import { expect } from '@playwright/test'; + +import test, { page } from './hooks'; +import { + ACP_BDD_FIXTURE_HOOK_TIMEOUT_MS, + type AcpBddFixtureRuntime, + ensureAgenticLayout, + loadAcpBddFixtureWorkbench, +} from './utils/acp-bdd-fixture'; +import { createBddEvidence } from './utils/bdd-evidence'; + +const LONG_STREAM_PROMPT = 'BDD layout stress long content'; +const LONG_CONTENT_SENTINEL = 'BDD_LONG_STREAM_CHUNK_40'; + +let runtime: AcpBddFixtureRuntime; + +function chatSlot() { + return page.locator('.AI-Chat-slot'); +} + +interface LayoutBoundsProof { + viewport: { + width: number; + height: number; + }; + chatSlot?: RectProof; + workbench?: RectProof; + messageViewport?: RectProof; + messageList?: RectProof; + input?: RectProof; + messageCount: number; + overflowingMessageCount: number; + pageHasHorizontalOverflow: boolean; + messageListScrollable: boolean; +} + +interface RectProof { + x: number; + y: number; + top: number; + width: number; + height: number; + right: number; + bottom: number; +} + +async function loadLongStreamWorkbench() { + runtime = await loadAcpBddFixtureWorkbench(page, { + fixture: 'long-stream', + profile: 'interactive', + delayMs: 25, + longStreamTicks: 220, + showChatView: true, + ensureAgenticLayout: true, + viewport: { width: 1440, height: 820 }, + }); + await expect(page.getByRole('heading', { name: 'AI Assistant' })).toBeVisible(); +} + +function chatInput() { + return chatSlot().locator('[contenteditable="true"]').last(); +} + +function chatButton(name: string) { + return chatSlot().getByRole('button', { name }); +} + +async function sendPrompt(prompt: string) { + const input = chatInput(); + await expect(input).toBeVisible(); + await input.click(); + await page.keyboard.type(prompt); + await chatButton('Send').click(); +} + +async function stopStreamIfActive() { + const stopButton = chatButton('Stop'); + if (await stopButton.isVisible().catch(() => false)) { + await stopButton.click(); + await expect(chatButton('Send')).toBeVisible({ timeout: 30_000 }); + } +} + +async function readLayoutBounds(): Promise { + return page.evaluate(() => { + const toRect = (rect: DOMRect): RectProof => ({ + x: rect.x, + y: rect.y, + top: rect.top, + width: rect.width, + height: rect.height, + right: rect.right, + bottom: rect.bottom, + }); + const isVisible = (element: Element) => { + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + return rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden' && style.display !== 'none'; + }; + + const chatSlot = document.querySelector('.AI-Chat-slot'); + const workbench = document.querySelector('#workbench-editor'); + const leftContainer = document.querySelector('#ai_chat_left_container'); + const messageViewport = leftContainer?.firstElementChild; + const messageList = leftContainer?.querySelector('.rce-mlist'); + const input = leftContainer?.querySelector('[contenteditable="true"]'); + const messageRows = Array.from(leftContainer?.querySelectorAll('.rce-container-mbox') || []).filter(isVisible); + const chatRect = chatSlot?.getBoundingClientRect(); + const inputRect = input?.getBoundingClientRect(); + + const overflowingMessageCount = chatRect + ? messageRows.filter((row) => { + const rect = row.getBoundingClientRect(); + return rect.left < chatRect.left - 2 || rect.right > chatRect.right + 2; + }).length + : messageRows.length; + + return { + viewport: { + width: window.innerWidth, + height: window.innerHeight, + }, + chatSlot: chatRect ? toRect(chatRect) : undefined, + workbench: workbench ? toRect(workbench.getBoundingClientRect()) : undefined, + messageViewport: messageViewport ? toRect(messageViewport.getBoundingClientRect()) : undefined, + messageList: messageList ? toRect(messageList.getBoundingClientRect()) : undefined, + input: inputRect ? toRect(inputRect) : undefined, + messageCount: messageRows.length, + overflowingMessageCount, + pageHasHorizontalOverflow: document.documentElement.scrollWidth > window.innerWidth + 2, + messageListScrollable: messageViewport ? messageViewport.scrollHeight > messageViewport.clientHeight + 8 : false, + }; + }); +} + +function expectLayoutBounds(proof: LayoutBoundsProof) { + expect(proof.chatSlot?.width).toBeGreaterThanOrEqual(640); + expect(proof.chatSlot?.right).toBeLessThanOrEqual(proof.viewport.width + 2); + expect(proof.workbench?.width).toBeGreaterThan(0); + expect(proof.messageCount).toBeGreaterThanOrEqual(2); + expect(proof.overflowingMessageCount).toBe(0); + expect(proof.pageHasHorizontalOverflow).toBe(false); + expect(proof.messageListScrollable).toBe(true); + expect(proof.messageViewport?.bottom).toBeLessThanOrEqual((proof.input?.top ?? Number.POSITIVE_INFINITY) + 2); +} + +test.describe('ACP Chat Agentic Layout Stress', () => { + test.setTimeout(ACP_BDD_FIXTURE_HOOK_TIMEOUT_MS); + + test.beforeAll(async () => { + test.setTimeout(ACP_BDD_FIXTURE_HOOK_TIMEOUT_MS); + await loadLongStreamWorkbench(); + }); + + test.afterAll(async () => { + await stopStreamIfActive(); + await runtime?.dispose(); + }); + + test('Layout Stress keeps long-stream content inside Agentic bounds', async ({ browser: _browser }, testInfo) => { + const evidence = createBddEvidence(testInfo, 'acp-chat-agentic-layout-stress', { + sourceScenario: 'test/bdd/acp-chat-agentic-layout-stress.scenario.md', + profile: 'interactive', + executionMode: 'deterministic-fixture', + hardeningVerdict: 'CONVERT', + }); + + await sendPrompt(LONG_STREAM_PROMPT); + await expect(chatSlot().getByText(LONG_CONTENT_SENTINEL)).toBeVisible({ timeout: 30_000 }); + await expect(chatButton('Stop')).toBeVisible(); + + const wideBounds = await readLayoutBounds(); + expectLayoutBounds(wideBounds); + const wideProof = await evidence.saveJson( + '01-wide-layout-bounds', + wideBounds, + 'long-stream content remains within Agentic layout bounds at the default viewport', + ); + + await page.setViewportSize({ width: 1366, height: 768 }); + await ensureAgenticLayout(page); + await expect(chatSlot().getByText(LONG_CONTENT_SENTINEL)).toBeVisible(); + + const narrowBounds = await readLayoutBounds(); + expectLayoutBounds(narrowBounds); + const narrowProof = await evidence.saveJson( + '02-narrow-layout-bounds', + narrowBounds, + 'long-stream content remains within Agentic layout bounds after viewport resize', + ); + + await stopStreamIfActive(); + + evidence.recordCriticalPoint({ + id: 'CP1', + requirement: 'Long deterministic stream content stays inside the Agentic chat bounds.', + status: 'pass', + evidence: [wideProof, narrowProof].filter(Boolean) as string[], + }); + evidence.recordCriticalPoint({ + id: 'CP2', + requirement: 'The long message list remains scrollable without overlapping the input.', + status: 'pass', + evidence: [wideProof, narrowProof].filter(Boolean) as string[], + }); + + await evidence.finalize({ + scenarioVerdict: 'PASS', + hardeningVerdict: 'CONVERT', + runtime: { + url: page.url(), + viewport: page.viewportSize(), + browserSurface: 'Playwright Chromium', + fixture: runtime.fixture, + profile: runtime.profile, + }, + }); + }); +}); diff --git a/tools/playwright/src/tests/acp-chat-agentic-reload-during-stream.test.ts b/tools/playwright/src/tests/acp-chat-agentic-reload-during-stream.test.ts new file mode 100644 index 0000000000..a39b535388 --- /dev/null +++ b/tools/playwright/src/tests/acp-chat-agentic-reload-during-stream.test.ts @@ -0,0 +1,153 @@ +// Source: test/bdd/acp-chat-agentic-reload-during-stream.scenario.md + +import { expect } from '@playwright/test'; + +import test, { page } from './hooks'; +import { + ACP_BDD_FIXTURE_HOOK_TIMEOUT_MS, + type AcpBddFixtureRuntime, + ensureAgenticLayout, + loadAcpBddFixtureWorkbench, + waitForAcpChatReady, + waitForWorkbenchReady, +} from './utils/acp-bdd-fixture'; +import { createBddEvidence } from './utils/bdd-evidence'; + +const LONG_STREAM_PROMPT = 'BDD reload during long stream'; +const ACTIVE_STREAM_SENTINEL = 'BDD_LONG_STREAM_CHUNK_02'; +const POST_RELOAD_DRAFT = 'BDD post reload draft'; + +let runtime: AcpBddFixtureRuntime; + +function chatSlot() { + return page.locator('.AI-Chat-slot'); +} + +async function loadLongStreamWorkbench() { + runtime = await loadAcpBddFixtureWorkbench(page, { + fixture: 'long-stream', + profile: 'interactive', + delayMs: 40, + longStreamTicks: 160, + showChatView: true, + ensureAgenticLayout: true, + viewport: { width: 1600, height: 900 }, + }); + await expect(page.getByRole('heading', { name: 'AI Assistant' })).toBeVisible(); +} + +function chatInput() { + return chatSlot().locator('[contenteditable="true"]').last(); +} + +function chatButton(name: string) { + return chatSlot().getByRole('button', { name }); +} + +async function sendPrompt(prompt: string) { + const input = chatInput(); + await expect(input).toBeVisible(); + await input.click(); + await page.keyboard.type(prompt); + await chatButton('Send').click(); +} + +async function showAcpChatView() { + await page.waitForFunction(() => Boolean((navigator as any).modelContext?.executeTool), undefined, { + timeout: 60_000, + }); + await page.evaluate(async () => { + await (navigator as any).modelContext.executeTool('acp_chat_show_chat_view', {}); + }); + await waitForAcpChatReady(page); + await ensureAgenticLayout(page); +} + +test.describe('ACP Chat Agentic Reload During Stream', () => { + test.setTimeout(ACP_BDD_FIXTURE_HOOK_TIMEOUT_MS); + + test.beforeAll(async () => { + test.setTimeout(ACP_BDD_FIXTURE_HOOK_TIMEOUT_MS); + await loadLongStreamWorkbench(); + }); + + test.afterAll(async () => { + await runtime?.dispose(); + }); + + test('Reload During Stream recovers to a usable Agentic chat shell', async ({ browser: _browser }, testInfo) => { + const evidence = createBddEvidence(testInfo, 'acp-chat-agentic-reload-during-stream', { + sourceScenario: 'test/bdd/acp-chat-agentic-reload-during-stream.scenario.md', + profile: 'interactive', + executionMode: 'deterministic-fixture', + hardeningVerdict: 'CONVERT', + }); + + await sendPrompt(LONG_STREAM_PROMPT); + await expect(chatSlot().getByText(ACTIVE_STREAM_SENTINEL)).toBeVisible({ timeout: 30_000 }); + await expect(chatButton('Stop')).toBeVisible(); + + const beforeReloadProof = await evidence.saveJson( + '01-active-before-reload', + { + url: page.url(), + hasActiveSentinel: await chatSlot().getByText(ACTIVE_STREAM_SENTINEL).isVisible(), + stopVisible: await chatButton('Stop').isVisible(), + }, + 'long-stream request is active immediately before browser reload', + ); + + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitForWorkbenchReady(page); + await showAcpChatView(); + + await expect(page.getByRole('heading', { name: 'AI Assistant' })).toBeVisible({ timeout: 30_000 }); + await expect(chatButton('Send')).toBeVisible({ timeout: 30_000 }); + await expect(chatButton('Stop')).toBeHidden(); + + const input = chatInput(); + await expect(input).toBeVisible(); + await input.click(); + await page.keyboard.type(POST_RELOAD_DRAFT); + await expect(input).toContainText(POST_RELOAD_DRAFT); + + const afterReloadProof = await evidence.saveJson( + '02-usable-after-reload', + { + url: page.url(), + headingVisible: await page.getByRole('heading', { name: 'AI Assistant' }).isVisible(), + sendVisible: await chatButton('Send').isVisible(), + stopVisible: await chatButton('Stop') + .isVisible() + .catch(() => false), + inputText: await input.textContent(), + }, + 'browser reload recovers to a usable Agentic chat shell', + ); + + evidence.recordCriticalPoint({ + id: 'CP1', + requirement: 'The deterministic long-stream fixture is visibly active before reload.', + status: 'pass', + evidence: [beforeReloadProof].filter(Boolean) as string[], + }); + evidence.recordCriticalPoint({ + id: 'CP2', + requirement: 'Reload returns to the same workspace with a usable Agentic chat shell.', + status: 'pass', + evidence: [afterReloadProof].filter(Boolean) as string[], + }); + + await evidence.finalize({ + scenarioVerdict: 'PASS', + hardeningVerdict: 'CONVERT', + runtime: { + url: page.url(), + viewport: page.viewportSize(), + browserSurface: 'Playwright Chromium', + fixture: runtime.fixture, + profile: runtime.profile, + }, + }); + }); +}); diff --git a/tools/playwright/src/tests/acp-chat-agentic-rich-history-restore.test.ts b/tools/playwright/src/tests/acp-chat-agentic-rich-history-restore.test.ts new file mode 100644 index 0000000000..02a3cf6cac --- /dev/null +++ b/tools/playwright/src/tests/acp-chat-agentic-rich-history-restore.test.ts @@ -0,0 +1,360 @@ +// Source: test/bdd/acp-chat-agentic-rich-history-restore.scenario.md + +import { expect } from '@playwright/test'; + +import test, { page } from './hooks'; +import { + ACP_BDD_FIXTURE_HOOK_TIMEOUT_MS, + type AcpBddFixtureRuntime, + ensureAgenticLayout, + loadAcpBddFixtureWorkbench, + waitForAcpChatReady, + waitForWorkbenchReady, +} from './utils/acp-bdd-fixture'; +import { createBddEvidence } from './utils/bdd-evidence'; + +const SESSION_PREFIX = 'bdd-rich-history'; +const SEEDED_SESSION_IDS = [`acp:${SESSION_PREFIX}-alpha`, `acp:${SESSION_PREFIX}-beta`]; +const RICH_PROMPT = 'BDD rich history restore'; +const METADATA_LEAK_SENTINELS = [ + 'BDD_ASSISTANT_PART', + 'BDD_THOUGHT_STEP', + 'BDD_TOOL_RESULT', + 'BDD_USER_TURN', + 'BDD_HISTORY_USER', + 'BDD_HISTORY_THOUGHT', + 'BDD_HISTORY_ASSISTANT', + 'BDD_HISTORY_TOOL_RESULT', +]; + +let runtime: AcpBddFixtureRuntime; + +interface AcpSessionSummary { + sessionId: string; + rawSessionId?: string; + title: string; + createdAt: number; + requestCount: number; + historyMessageCount: number; + slicedMessageCount: number; + threadStatus?: string; +} + +interface RichUiProof { + userRows: number; + assistantRows: number; + reasoningToggleCount: number; + toolCardCount: number; + hasPlanChecklistText: boolean; + sendVisible: boolean; + stopVisible: boolean; +} + +async function loadHistoryWorkbench() { + runtime = await loadAcpBddFixtureWorkbench(page, { + fixture: 'history', + profile: 'interactive', + delayMs: 20, + sessionPrefix: SESSION_PREFIX, + showChatView: true, + ensureAgenticLayout: true, + viewport: { width: 1600, height: 900 }, + }); + await expect(page.getByRole('heading', { name: 'AI Assistant' })).toBeVisible(); +} + +async function showAcpChatView() { + await page.waitForFunction(() => Boolean((navigator as any).modelContext?.executeTool), undefined, { + timeout: 60_000, + }); + await page.evaluate(async () => { + await (navigator as any).modelContext.executeTool('acp_chat_show_chat_view', {}); + }); + await waitForAcpChatReady(page); + await ensureAgenticLayout(page); +} + +async function executeAcpTool(name: string, args: Record = {}) { + return page.evaluate( + async ({ toolName, toolArgs }) => (navigator as any).modelContext.executeTool(toolName, toolArgs), + { toolName: name, toolArgs: args }, + ) as Promise<{ success: boolean; result: T }>; +} + +async function listSessions(): Promise { + const result = await executeAcpTool<{ sessions: AcpSessionSummary[]; total: number }>('acp_chat_list_sessions'); + expect(result.success).toBe(true); + return result.result.sessions; +} + +async function getSessionState() { + const result = await executeAcpTool<{ active: boolean; session: AcpSessionSummary | null }>( + 'acp_chat_get_session_state', + ); + expect(result.success).toBe(true); + return result.result; +} + +async function waitForSeededSessions(): Promise { + await expect + .poll( + async () => { + const sessions = await listSessions(); + return sessions + .map((session) => session.sessionId) + .filter((id) => SEEDED_SESSION_IDS.includes(id)) + .sort(); + }, + { timeout: 30_000 }, + ) + .toEqual([...SEEDED_SESSION_IDS].sort()); + + return (await listSessions()).filter((session) => SEEDED_SESSION_IDS.includes(session.sessionId)); +} + +async function ensureHistoryVisible() { + const inline = page.locator('[data-testid="acp-chat-history-inline"]'); + if (await inline.isVisible().catch(() => false)) { + return; + } + + const collapsed = page.locator('[data-testid="acp-chat-history-collapsed"]'); + if (await collapsed.isVisible().catch(() => false)) { + await page.getByLabel(/Expand Chat History|展开聊天历史/).click(); + await expect(inline).toBeVisible({ timeout: 30_000 }); + return; + } + + const popoverButton = page.locator('[data-testid="acp-chat-history-button"]'); + await expect(popoverButton).toBeVisible({ timeout: 30_000 }); + await popoverButton.click(); + await expect(page.locator('[data-testid="acp-chat-history-popover"]')).toBeVisible({ timeout: 30_000 }); +} + +async function clickHistoryItem(sessionId: string) { + await ensureHistoryVisible(); + const row = page.locator(`[data-testid="chat-history-item-${sessionId}"]`).first(); + await expect(row).toBeVisible({ timeout: 30_000 }); + await row.click(); + await expect + .poll( + async () => { + const state = await getSessionState(); + return state.session?.sessionId; + }, + { timeout: 30_000 }, + ) + .toBe(sessionId); +} + +function chatInput() { + return chatSlot().locator('[contenteditable="true"]').last(); +} + +function chatSlot() { + return page.locator('.AI-Chat-slot'); +} + +function sendButton() { + return chatSlot() + .getByRole('button', { name: /^(Enter\s+)?Send$|^Enter\s+发送$|^发送$/i }) + .last(); +} + +async function sendPromptAndWaitForRichUi(prompt: string) { + const input = chatInput(); + await expect(input).toBeVisible(); + await input.click(); + await page.keyboard.type(prompt); + await expect(sendButton()).toBeVisible(); + await sendButton().click(); + + await expect( + page + .locator('.AI-Chat-slot') + .getByText(/Deep Thinking|深度思考/) + .last(), + ).toBeVisible({ + timeout: 30_000, + }); + await expect(page.locator('.AI-Chat-slot').getByText('Called MCP Tool').last()).toBeVisible({ timeout: 30_000 }); + await expect(sendButton()).toBeVisible({ timeout: 30_000 }); +} + +async function readRichUiProof(): Promise { + return page.evaluate(() => { + const slot = document.querySelector('.AI-Chat-slot') as HTMLElement | null; + const text = slot?.innerText || ''; + const countText = (needle: string) => text.split(needle).length - 1; + const visibleButtons = Array.from( + slot?.querySelectorAll('button, [role="button"], [aria-label]') || [], + ).filter((button) => { + const rect = button.getBoundingClientRect(); + const style = window.getComputedStyle(button); + return rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden' && style.display !== 'none'; + }); + const hasVisibleButton = (pattern: RegExp) => + visibleButtons.some((button) => + pattern.test([button.innerText, button.getAttribute('aria-label'), button.getAttribute('title')].join(' ')), + ); + + return { + userRows: slot?.querySelectorAll('.rce-user-msg').length || 0, + assistantRows: slot?.querySelectorAll('.rce-ai-msg').length || 0, + reasoningToggleCount: visibleButtons.filter((button) => /Deep Thinking|深度思考/.test(button.innerText)).length, + toolCardCount: countText('Called MCP Tool'), + hasPlanChecklistText: text.includes('BDD plan:'), + sendVisible: hasVisibleButton(/Send|发送/), + stopVisible: hasVisibleButton(/Stop|停止/), + }; + }); +} + +function expectRichUiRestored(proof: RichUiProof, baseline: RichUiProof) { + expect(proof.userRows).toBe(baseline.userRows + 1); + expect(proof.assistantRows).toBeGreaterThanOrEqual(baseline.assistantRows + 1); + expect(proof.reasoningToggleCount).toBeGreaterThanOrEqual(baseline.reasoningToggleCount + 1); + expect(proof.toolCardCount).toBeGreaterThanOrEqual(baseline.toolCardCount + 1); + expect(proof.hasPlanChecklistText).toBe(true); + expect(proof.sendVisible).toBe(true); + expect(proof.stopVisible).toBe(false); +} + +function expectMetadataOnly(value: unknown) { + const serialized = JSON.stringify(value); + for (const sentinel of METADATA_LEAK_SENTINELS) { + expect(serialized).not.toContain(sentinel); + } +} + +test.describe('ACP Chat Agentic Rich History Restore', () => { + test.setTimeout(ACP_BDD_FIXTURE_HOOK_TIMEOUT_MS); + + test.beforeAll(async () => { + test.setTimeout(ACP_BDD_FIXTURE_HOOK_TIMEOUT_MS); + await loadHistoryWorkbench(); + }); + + test.afterAll(async () => { + await runtime?.dispose(); + }); + + test('Rich History Restore keeps structured fixture UI across session switching', async ({ + browser: _browser, + }, testInfo) => { + const evidence = createBddEvidence(testInfo, 'acp-chat-agentic-rich-history-restore', { + sourceScenario: 'test/bdd/acp-chat-agentic-rich-history-restore.scenario.md', + profile: 'interactive', + executionMode: 'deterministic-fixture', + hardeningVerdict: 'CONVERT', + }); + + const [richSession, otherSession] = await waitForSeededSessions(); + await clickHistoryItem(otherSession.sessionId); + const otherBaseline = await readRichUiProof(); + + await clickHistoryItem(richSession.sessionId); + const richBaseline = await readRichUiProof(); + await sendPromptAndWaitForRichUi(RICH_PROMPT); + + const initialRichProof = await readRichUiProof(); + expectRichUiRestored(initialRichProof, richBaseline); + const initialProof = await evidence.saveJson( + '01-rich-ui-before-switch', + { activeSession: await getSessionState(), ui: initialRichProof }, + 'history fixture rich response before session switching', + ); + + await clickHistoryItem(otherSession.sessionId); + const otherSessionProof = await readRichUiProof(); + expect(otherSessionProof.userRows).toBe(otherBaseline.userRows); + expect(otherSessionProof.assistantRows).toBe(otherBaseline.assistantRows); + expect(otherSessionProof.toolCardCount).toBe(otherBaseline.toolCardCount); + expect(otherSessionProof.reasoningToggleCount).toBe(otherBaseline.reasoningToggleCount); + + await clickHistoryItem(richSession.sessionId); + const restoredRichProof = await readRichUiProof(); + expectRichUiRestored(restoredRichProof, richBaseline); + const restoredProof = await evidence.saveJson( + '02-rich-ui-after-switch-back', + { activeSession: await getSessionState(), ui: restoredRichProof }, + 'rich reasoning, plan, and tool-call UI after switching away and back', + ); + + const state = await getSessionState(); + const sessions = await listSessions(); + expect(state.active).toBe(true); + expect(state.session?.sessionId).toBe(richSession.sessionId); + expect(state.session?.title).toBeTruthy(); + expectMetadataOnly({ state, sessions }); + + const metadataProof = await evidence.saveJson( + '03-metadata-only-after-rich-restore', + { state, sessions }, + 'state and list tools stay metadata-only after rich history restore', + ); + + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitForWorkbenchReady(page); + await showAcpChatView(); + await waitForSeededSessions(); + await clickHistoryItem(richSession.sessionId); + + const postReloadState = await getSessionState(); + const postReloadSessions = await listSessions(); + const postReloadUi = await readRichUiProof(); + expect(postReloadState.active).toBe(true); + expect(postReloadState.session?.sessionId).toBe(richSession.sessionId); + expect(postReloadUi.userRows).toBeGreaterThanOrEqual(1); + expect(postReloadUi.userRows).toBeLessThanOrEqual(2); + expect(postReloadUi.assistantRows).toBeGreaterThanOrEqual(1); + expect(postReloadUi.assistantRows).toBeLessThanOrEqual(3); + expect(postReloadUi.stopVisible).toBe(false); + expectMetadataOnly({ postReloadState, postReloadSessions }); + + const postReloadProof = await evidence.saveJson( + '04-bounded-shell-after-reload', + { state: postReloadState, sessions: postReloadSessions, ui: postReloadUi }, + 'page reload recovers the deterministic session shell without metadata leakage', + ); + + evidence.recordCriticalPoint({ + id: 'CP1', + requirement: 'The history fixture emits visible reasoning, plan, and tool-call UI for a completed response.', + status: 'pass', + evidence: [initialProof].filter(Boolean) as string[], + }); + evidence.recordCriticalPoint({ + id: 'CP2', + requirement: 'Switching away and back restores the same rich response structure without duplicate user rows.', + status: 'pass', + evidence: [restoredProof].filter(Boolean) as string[], + }); + evidence.recordCriticalPoint({ + id: 'CP3', + requirement: 'State and list tools expose only bounded session metadata after rich response restore.', + status: 'pass', + evidence: [metadataProof].filter(Boolean) as string[], + }); + evidence.recordCriticalPoint({ + id: 'CP4', + requirement: 'Reload recovers a bounded visible shell for the rich session without stale loading state.', + status: 'pass', + evidence: [postReloadProof].filter(Boolean) as string[], + notes: + 'The existing loadSession history path restores transcript rows, but not full reasoning/tool response parts after page reload.', + }); + + await evidence.finalize({ + scenarioVerdict: 'PASS', + hardeningVerdict: 'CONVERT', + runtime: { + url: page.url(), + viewport: page.viewportSize(), + browserSurface: 'Playwright Chromium', + fixture: runtime.fixture, + profile: runtime.profile, + }, + }); + }); +}); diff --git a/tools/playwright/src/tests/acp-chat-agentic-session-isolation.test.ts b/tools/playwright/src/tests/acp-chat-agentic-session-isolation.test.ts new file mode 100644 index 0000000000..71bd4634c4 --- /dev/null +++ b/tools/playwright/src/tests/acp-chat-agentic-session-isolation.test.ts @@ -0,0 +1,354 @@ +// Source: test/bdd/acp-chat-agentic-session-isolation.scenario.md + +import { expect } from '@playwright/test'; + +import test, { page } from './hooks'; +import { + ACP_BDD_FIXTURE_HOOK_TIMEOUT_MS, + type AcpBddFixtureRuntime, + loadAcpBddFixtureWorkbench, +} from './utils/acp-bdd-fixture'; +import { createBddEvidence } from './utils/bdd-evidence'; + +const SESSION_PREFIX = 'bdd-session-isolation'; +const SEEDED_SESSION_IDS = [`acp:${SESSION_PREFIX}-alpha`, `acp:${SESSION_PREFIX}-beta`]; +const SESSION_A_PROMPT = 'BDD history isolation session A'; +const SESSION_B_PROMPT = 'BDD history isolation session B'; +const METADATA_LEAK_SENTINELS = [ + 'BDD_ASSISTANT_PART', + 'BDD_THOUGHT_STEP', + 'BDD_TOOL_RESULT', + 'BDD_USER_TURN', + 'BDD_HISTORY_USER', + 'BDD_HISTORY_THOUGHT', + 'BDD_HISTORY_ASSISTANT', + 'BDD_HISTORY_TOOL_RESULT', +]; + +let runtime: AcpBddFixtureRuntime; + +interface AcpSessionSummary { + sessionId: string; + rawSessionId?: string; + title: string; + createdAt: number; + requestCount: number; + historyMessageCount: number; + slicedMessageCount: number; + threadStatus?: string; +} + +interface SessionShellProof { + activeSessionId?: string; + userRows: number; + assistantRows: number; + reasoningToggleCount: number; + toolCardCount: number; + sendVisible: boolean; + stopVisible: boolean; +} + +async function loadHistoryWorkbench() { + runtime = await loadAcpBddFixtureWorkbench(page, { + fixture: 'history', + profile: 'interactive', + delayMs: 20, + sessionPrefix: SESSION_PREFIX, + showChatView: true, + ensureAgenticLayout: true, + viewport: { width: 1600, height: 900 }, + }); + await expect(page.getByRole('heading', { name: 'AI Assistant' })).toBeVisible(); +} + +async function executeAcpTool(name: string, args: Record = {}) { + return page.evaluate( + async ({ toolName, toolArgs }) => (navigator as any).modelContext.executeTool(toolName, toolArgs), + { toolName: name, toolArgs: args }, + ) as Promise<{ success: boolean; result: T }>; +} + +async function listSessions(): Promise { + const result = await executeAcpTool<{ sessions: AcpSessionSummary[]; total: number }>('acp_chat_list_sessions'); + expect(result.success).toBe(true); + return result.result.sessions; +} + +async function getSessionState() { + const result = await executeAcpTool<{ active: boolean; session: AcpSessionSummary | null }>( + 'acp_chat_get_session_state', + ); + expect(result.success).toBe(true); + return result.result; +} + +async function waitForSeededSessions(): Promise { + await expect + .poll( + async () => { + const sessions = await listSessions(); + return sessions + .map((session) => session.sessionId) + .filter((id) => SEEDED_SESSION_IDS.includes(id)) + .sort(); + }, + { timeout: 30_000 }, + ) + .toEqual([...SEEDED_SESSION_IDS].sort()); + + return (await listSessions()).filter((session) => SEEDED_SESSION_IDS.includes(session.sessionId)); +} + +async function ensureHistoryVisible() { + const inline = page.locator('[data-testid="acp-chat-history-inline"]'); + if (await inline.isVisible().catch(() => false)) { + return; + } + + const collapsed = page.locator('[data-testid="acp-chat-history-collapsed"]'); + if (await collapsed.isVisible().catch(() => false)) { + await page.getByLabel(/Expand Chat History|展开聊天历史/).click(); + await expect(inline).toBeVisible({ timeout: 30_000 }); + return; + } + + const popoverButton = page.locator('[data-testid="acp-chat-history-button"]'); + await expect(popoverButton).toBeVisible({ timeout: 30_000 }); + await popoverButton.click(); + await expect(page.locator('[data-testid="acp-chat-history-popover"]')).toBeVisible({ timeout: 30_000 }); +} + +async function clickHistoryItem(sessionId: string) { + await ensureHistoryVisible(); + const row = page.locator(`[data-testid="chat-history-item-${sessionId}"]`).first(); + await expect(row).toBeVisible({ timeout: 30_000 }); + await row.click(); + await expect + .poll( + async () => { + const state = await getSessionState(); + return state.session?.sessionId; + }, + { timeout: 30_000 }, + ) + .toBe(sessionId); +} + +function chatInput() { + return chatSlot().locator('[contenteditable="true"]').last(); +} + +function chatSlot() { + return page.locator('.AI-Chat-slot'); +} + +function sendButton() { + return chatSlot() + .getByRole('button', { name: /^(Enter\s+)?Send$|^Enter\s+发送$|^发送$/i }) + .last(); +} + +async function sendPromptAndWaitForResult(prompt: string) { + const input = chatInput(); + await expect(input).toBeVisible(); + await input.click(); + await page.keyboard.type(prompt); + await expect(sendButton()).toBeVisible(); + await sendButton().click(); + + await expect(page.locator('.AI-Chat-slot').getByText('Called MCP Tool').last()).toBeVisible({ timeout: 30_000 }); + await expect(sendButton()).toBeVisible({ timeout: 30_000 }); +} + +async function readSessionShellProof(): Promise { + const state = await getSessionState(); + const ui = await page.evaluate(() => { + const slot = document.querySelector('.AI-Chat-slot') as HTMLElement | null; + const text = slot?.innerText || ''; + const countText = (needle: string) => text.split(needle).length - 1; + const visibleButtons = Array.from( + slot?.querySelectorAll('button, [role="button"], [aria-label]') || [], + ).filter((button) => { + const rect = button.getBoundingClientRect(); + const style = window.getComputedStyle(button); + return rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden' && style.display !== 'none'; + }); + const hasVisibleButton = (pattern: RegExp) => + visibleButtons.some((button) => + pattern.test([button.innerText, button.getAttribute('aria-label'), button.getAttribute('title')].join(' ')), + ); + + return { + userRows: slot?.querySelectorAll('.rce-user-msg').length || 0, + assistantRows: slot?.querySelectorAll('.rce-ai-msg').length || 0, + reasoningToggleCount: visibleButtons.filter((button) => /Deep Thinking|深度思考/.test(button.innerText)).length, + toolCardCount: countText('Called MCP Tool'), + sendVisible: hasVisibleButton(/Send|发送/), + stopVisible: hasVisibleButton(/Stop|停止/), + }; + }); + + return { + activeSessionId: state.session?.sessionId, + ...ui, + }; +} + +function expectCompletedSingleTurnShell(proof: SessionShellProof, sessionId: string, baseline: SessionShellProof) { + expect(proof.activeSessionId).toBe(sessionId); + expect(proof.userRows).toBe(baseline.userRows + 1); + expect(proof.assistantRows).toBeGreaterThanOrEqual(baseline.assistantRows + 1); + expect(proof.assistantRows).toBeLessThanOrEqual(baseline.assistantRows + 2); + expect(proof.reasoningToggleCount).toBeGreaterThanOrEqual(baseline.reasoningToggleCount + 1); + expect(proof.toolCardCount).toBe(baseline.toolCardCount + 1); + expect(proof.sendVisible).toBe(true); + expect(proof.stopVisible).toBe(false); +} + +function expectMetadataOnly(value: unknown) { + const serialized = JSON.stringify(value); + for (const sentinel of METADATA_LEAK_SENTINELS) { + expect(serialized).not.toContain(sentinel); + } +} + +function sessionById(sessions: AcpSessionSummary[], sessionId: string): AcpSessionSummary { + const session = sessions.find((item) => item.sessionId === sessionId); + expect(session, `missing session ${sessionId}`).toBeDefined(); + return session!; +} + +test.describe('ACP Chat Agentic Session Isolation', () => { + test.setTimeout(ACP_BDD_FIXTURE_HOOK_TIMEOUT_MS); + + test.beforeAll(async () => { + test.setTimeout(ACP_BDD_FIXTURE_HOOK_TIMEOUT_MS); + await loadHistoryWorkbench(); + }); + + test.afterAll(async () => { + await runtime?.dispose(); + }); + + test('Session Isolation keeps history-backed sessions visually and metrically separate', async ({ + browser: _browser, + }, testInfo) => { + const evidence = createBddEvidence(testInfo, 'acp-chat-agentic-session-isolation', { + sourceScenario: 'test/bdd/acp-chat-agentic-session-isolation.scenario.md', + profile: 'interactive', + executionMode: 'deterministic-fixture', + hardeningVerdict: 'CONVERT', + }); + + const [sessionA, sessionB] = await waitForSeededSessions(); + + await clickHistoryItem(sessionA.sessionId); + const sessionABaseline = await readSessionShellProof(); + await clickHistoryItem(sessionB.sessionId); + const sessionBBaseline = await readSessionShellProof(); + + await clickHistoryItem(sessionA.sessionId); + await sendPromptAndWaitForResult(SESSION_A_PROMPT); + const sessionAAfterSend = await readSessionShellProof(); + expectCompletedSingleTurnShell(sessionAAfterSend, sessionA.sessionId, sessionABaseline); + const sessionAProof = await evidence.saveJson( + '01-session-a-complete', + sessionAAfterSend, + 'Session A completed one deterministic history-backed turn', + ); + + await clickHistoryItem(sessionB.sessionId); + const sessionBUnchanged = await readSessionShellProof(); + expect(sessionBUnchanged.activeSessionId).toBe(sessionB.sessionId); + expect(sessionBUnchanged.userRows).toBe(sessionBBaseline.userRows); + expect(sessionBUnchanged.assistantRows).toBe(sessionBBaseline.assistantRows); + expect(sessionBUnchanged.toolCardCount).toBe(sessionBBaseline.toolCardCount); + expect(sessionBUnchanged.reasoningToggleCount).toBe(sessionBBaseline.reasoningToggleCount); + const emptySessionBProof = await evidence.saveJson( + '02-session-b-empty-after-a', + sessionBUnchanged, + 'Session B baseline stays unchanged after Session A receives updates', + ); + + await sendPromptAndWaitForResult(SESSION_B_PROMPT); + const sessionBAfterSend = await readSessionShellProof(); + expectCompletedSingleTurnShell(sessionBAfterSend, sessionB.sessionId, sessionBBaseline); + const sessionBProof = await evidence.saveJson( + '03-session-b-complete', + sessionBAfterSend, + 'Session B completed one deterministic history-backed turn', + ); + + await clickHistoryItem(sessionA.sessionId); + const sessionARestored = await readSessionShellProof(); + expectCompletedSingleTurnShell(sessionARestored, sessionA.sessionId, sessionABaseline); + + await clickHistoryItem(sessionB.sessionId); + const sessionBRestored = await readSessionShellProof(); + expectCompletedSingleTurnShell(sessionBRestored, sessionB.sessionId, sessionBBaseline); + const restoredProof = await evidence.saveJson( + '04-switch-back-and-forth', + { sessionA: sessionARestored, sessionB: sessionBRestored }, + 'Switching back and forth keeps each history-backed session bounded to one visible turn', + ); + + const sessions = await listSessions(); + const state = await getSessionState(); + const summaryA = sessionById(sessions, sessionA.sessionId); + const summaryB = sessionById(sessions, sessionB.sessionId); + expect(summaryA.requestCount).toBe(1); + expect(summaryB.requestCount).toBe(1); + expect(state.session?.sessionId).toBe(sessionB.sessionId); + expectMetadataOnly({ state, sessions }); + const metadataProof = await evidence.saveJson( + '05-metadata-isolated', + { state, summaryA, summaryB }, + 'List and state tools expose bounded per-session metadata after history-backed isolation checks', + ); + + evidence.recordCriticalPoint({ + id: 'CP1', + requirement: 'Session A can complete a deterministic history-backed turn.', + status: 'pass', + evidence: [sessionAProof].filter(Boolean) as string[], + }); + evidence.recordCriticalPoint({ + id: 'CP2', + requirement: + 'Session B does not receive Session A visible rows, reasoning UI, or tool cards before its own turn.', + status: 'pass', + evidence: [emptySessionBProof].filter(Boolean) as string[], + }); + evidence.recordCriticalPoint({ + id: 'CP3', + requirement: 'Session B can complete its own deterministic turn and remain visually separate.', + status: 'pass', + evidence: [sessionBProof].filter(Boolean) as string[], + }); + evidence.recordCriticalPoint({ + id: 'CP4', + requirement: 'Switching back and forth keeps each session bounded to its own one-turn shell.', + status: 'pass', + evidence: [restoredProof].filter(Boolean) as string[], + }); + evidence.recordCriticalPoint({ + id: 'CP5', + requirement: 'Per-session request counts remain isolated in metadata-only list/state tools.', + status: 'pass', + evidence: [metadataProof].filter(Boolean) as string[], + notes: 'Concurrent long-stream isolation remains out of scope for this history-only fixture pass.', + }); + + await evidence.finalize({ + scenarioVerdict: 'PASS', + hardeningVerdict: 'CONVERT', + runtime: { + url: page.url(), + viewport: page.viewportSize(), + browserSurface: 'Playwright Chromium', + fixture: runtime.fixture, + profile: runtime.profile, + }, + }); + }); +}); diff --git a/tools/playwright/src/tests/acp-chat-agentic-side-entry-filter.test.ts b/tools/playwright/src/tests/acp-chat-agentic-side-entry-filter.test.ts new file mode 100644 index 0000000000..0c77922187 --- /dev/null +++ b/tools/playwright/src/tests/acp-chat-agentic-side-entry-filter.test.ts @@ -0,0 +1,147 @@ +// Source: test/bdd/acp-chat-agentic-side-entry-filter.scenario.md + +import path from 'path'; + +import { expect } from '@playwright/test'; + +import { OpenSumiApp } from '../app'; +import { OpenSumiWorkspace } from '../workspace'; + +import test, { page } from './hooks'; +import { + aiNativeWorkbenchUrl, + ensureAgenticLayout, + waitForAcpChatReady, + waitForExplorerViewVisible, + waitForWorkbenchReady, + writeAiNativePanelLayoutSettings, +} from './utils/acp-bdd-fixture'; +import { createBddEvidence } from './utils/bdd-evidence'; + +let app: OpenSumiApp; +let workspace: OpenSumiWorkspace; + +const STANDARD_LEFT_CONTAINER_IDS = ['explorer', 'search', 'scm', 'debug', 'extension']; + +async function showAcpChatIfAvailable() { + await page + .waitForFunction(() => Boolean((navigator as any).modelContext?.executeTool), undefined, { timeout: 15_000 }) + .catch(() => undefined); + + await page + .evaluate(async () => { + const modelContext = (navigator as any).modelContext; + if (!modelContext?.executeTool) { + return; + } + const tools = await modelContext.getTools?.(); + if (!Array.isArray(tools) || tools.some((tool: { name: string }) => tool.name === 'acp_chat_show_chat_view')) { + await modelContext.executeTool('acp_chat_show_chat_view', {}); + } + }) + .catch(() => undefined); + await waitForAcpChatReady(page).catch(() => undefined); +} + +async function getVisibleStandardSideEntries(): Promise { + return page.evaluate((standardIds) => { + const leftTabbar = document.querySelector('#opensumi-left-tabbar'); + if (!leftTabbar) { + return []; + } + + return Array.from(leftTabbar.querySelectorAll('li[id]')) + .map((entry) => entry.id) + .filter((id) => standardIds.includes(id)); + }, STANDARD_LEFT_CONTAINER_IDS); +} + +async function clickSideEntry(containerId: string) { + await page.locator(`#opensumi-left-tabbar li#${containerId}`).click(); +} + +test.describe('ACP Chat Agentic side entry filter', () => { + test.beforeAll(async () => { + await page.setViewportSize({ width: 1800, height: 1000 }); + workspace = new OpenSumiWorkspace([path.resolve(__dirname, '../../src/tests/workspaces/default')]); + await workspace.initWorksapce(); + await writeAiNativePanelLayoutSettings(workspace.workspace.codeUri.fsPath, 'agentic'); + app = new OpenSumiApp(page); + await page.goto(aiNativeWorkbenchUrl(workspace.workspace.codeUri.fsPath)); + await waitForWorkbenchReady(page); + }); + + test.afterAll(() => { + app.dispose(); + workspace.dispose(); + }); + + test('shows only Explorer and Git side entries in Agentic layout', async ({ browser: _browser }, testInfo) => { + const evidence = createBddEvidence(testInfo, 'acp-chat-agentic-side-entry-filter', { + sourceScenario: 'test/bdd/acp-chat-agentic-side-entry-filter.scenario.md', + profile: 'default', + executionMode: 'deterministic-ui', + hardeningVerdict: 'CONVERT', + }); + + await showAcpChatIfAvailable(); + await ensureAgenticLayout(page); + + const agenticEntries = await getVisibleStandardSideEntries(); + const agenticProof = await evidence.saveJson( + '01-agentic-side-entries', + { entries: agenticEntries }, + 'Agentic left side entries', + ); + + expect(agenticEntries).toEqual(['explorer', 'scm']); + + await clickSideEntry('scm'); + await expect(page.locator('#opensumi-left-tabbar li#scm')).toHaveClass(/active/); + + await waitForExplorerViewVisible(page); + + await writeAiNativePanelLayoutSettings(workspace.workspace.codeUri.fsPath, 'classic'); + await page.goto(aiNativeWorkbenchUrl(workspace.workspace.codeUri.fsPath, 'default', 'classic')); + await waitForWorkbenchReady(page); + await showAcpChatIfAvailable(); + await expect + .poll(getVisibleStandardSideEntries, { timeout: 30_000 }) + .toEqual(expect.arrayContaining(['explorer', 'search', 'scm', 'debug', 'extension'])); + const classicEntries = await getVisibleStandardSideEntries(); + const classicProof = await evidence.saveJson( + '02-classic-side-entries', + { entries: classicEntries }, + 'Classic left side entries', + ); + + evidence.recordCriticalPoint({ + id: 'CP1', + requirement: 'Agentic side entries show only Explorer and Git/SCM.', + status: 'pass', + evidence: [agenticProof].filter(Boolean) as string[], + }); + evidence.recordCriticalPoint({ + id: 'CP2', + requirement: 'Explorer and Git/SCM entries remain interactive in Agentic layout.', + status: 'pass', + evidence: [agenticProof].filter(Boolean) as string[], + }); + evidence.recordCriticalPoint({ + id: 'CP3', + requirement: 'Classic layout keeps the broader standard Activity Bar entries.', + status: 'pass', + evidence: [classicProof].filter(Boolean) as string[], + }); + + await evidence.finalize({ + scenarioVerdict: 'PASS', + hardeningVerdict: 'CONVERT', + runtime: { + url: page.url(), + viewport: page.viewportSize(), + browserSurface: 'Playwright Chromium', + }, + }); + }); +}); diff --git a/tools/playwright/src/tests/acp-chat-agentic-startup.test.ts b/tools/playwright/src/tests/acp-chat-agentic-startup.test.ts new file mode 100644 index 0000000000..e038acdbc0 --- /dev/null +++ b/tools/playwright/src/tests/acp-chat-agentic-startup.test.ts @@ -0,0 +1,152 @@ +// Source: test/bdd/acp-chat-agentic-startup.scenario.md + +import path from 'path'; + +import { expect } from '@playwright/test'; + +import { OpenSumiApp } from '../app'; +import { OpenSumiWorkspace } from '../workspace'; + +import test, { page } from './hooks'; +import { + aiNativeWorkbenchUrl, + ensureAgenticLayout, + waitForAcpChatReady, + waitForExplorerViewVisible, + waitForWorkbenchReady, + writeAiNativePanelLayoutSettings, +} from './utils/acp-bdd-fixture'; +import { createBddEvidence } from './utils/bdd-evidence'; + +let app: OpenSumiApp; +let workspace: OpenSumiWorkspace; + +test.describe('ACP Chat Agentic startup layout', () => { + test.beforeAll(async () => { + await page.setViewportSize({ width: 1800, height: 1000 }); + workspace = new OpenSumiWorkspace([path.resolve(__dirname, '../../src/tests/workspaces/default')]); + await workspace.initWorksapce(); + await writeAiNativePanelLayoutSettings(workspace.workspace.codeUri.fsPath, 'agentic'); + app = new OpenSumiApp(page); + await page.goto(aiNativeWorkbenchUrl(workspace.workspace.codeUri.fsPath)); + await waitForWorkbenchReady(page); + }); + + test.afterAll(() => { + app.dispose(); + workspace.dispose(); + }); + + test('starts with a usable Agentic chat layout and safe default tool surface', async ({ + browser: _browser, + }, testInfo) => { + const evidence = createBddEvidence(testInfo, 'acp-chat-agentic-startup', { + sourceScenario: 'test/bdd/acp-chat-agentic-startup.scenario.md', + profile: 'default', + executionMode: 'deterministic-fixture', + hardeningVerdict: 'CONVERT', + }); + + await page.waitForFunction(() => Boolean((navigator as any).modelContext?.executeTool)); + await page.evaluate(async () => { + await (navigator as any).modelContext.executeTool('acp_chat_show_chat_view', {}); + }); + + await ensureAgenticLayout(page); + await waitForAcpChatReady(page); + await expect(page.locator('.AI-Chat-slot')).not.toContainText('Initializing ACP service'); + await waitForExplorerViewVisible(page); + + const layout = await page.evaluate(async () => { + const modelContext = (navigator as any).modelContext; + const tools = await modelContext.getTools(); + const toolNames = tools.map((tool: { name: string }) => tool.name).sort(); + const state = await modelContext.executeTool('acp_chat_get_session_state', {}); + const permission = await modelContext.executeTool('acp_chat_get_permission_state', {}); + const aiChat = document.querySelector('.AI-Chat-slot')?.getBoundingClientRect(); + const workbench = document.querySelector('#workbench-editor')?.getBoundingClientRect(); + const statusVisible = Array.from(document.querySelectorAll('body *')).some((element) => { + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + return ( + rect.y > window.innerHeight - 80 && + rect.width > 200 && + rect.height >= 18 && + rect.height <= 48 && + style.visibility !== 'hidden' && + style.display !== 'none' + ); + }); + + return { + acpTools: toolNames.filter((name: string) => name.startsWith('acp_chat')), + forbiddenTools: toolNames.filter( + (name: string) => + /[A-Z]/.test(name) || + name.startsWith('_opensumi/') || + [ + 'acp_sendMessage', + 'acp_createSession', + 'acp_switchSession', + 'acp_clearSession', + 'acp_cancelRequest', + 'acp_handlePermissionDialog', + ].includes(name), + ), + aiChat: aiChat && { x: aiChat.x, width: aiChat.width, height: aiChat.height }, + workbench: workbench && { x: workbench.x, width: workbench.width, height: workbench.height }, + statusVisible, + state, + permission, + }; + }); + const layoutProof = await evidence.saveJson( + '01-layout-and-tools', + layout, + 'layout geometry and default tool surface', + ); + const layoutScreenshot = await evidence.captureScreenshot(page, '02-agentic-startup', 'Agentic chat startup UI'); + + expect(layout.acpTools).toEqual([ + 'acp_chat_get_permission_state', + 'acp_chat_get_session_state', + 'acp_chat_show_chat_view', + ]); + expect(layout.forbiddenTools).toEqual([]); + expect(layout.aiChat?.x).toBeLessThan(layout.workbench?.x ?? Number.POSITIVE_INFINITY); + expect(layout.aiChat?.width).toBeGreaterThanOrEqual(640); + expect(layout.aiChat?.width).toBeLessThanOrEqual(1440); + expect(layout.workbench?.width).toBeGreaterThanOrEqual(480); + expect(layout.statusVisible).toBe(true); + expect(layout.state.success).toBe(true); + expect(layout.permission.success).toBe(true); + + evidence.recordCriticalPoint({ + id: 'CP1', + requirement: 'Agentic AI Chat opens as the leftmost major surface with Explorer and status bar visible.', + status: 'pass', + evidence: [layoutProof, layoutScreenshot].filter(Boolean) as string[], + }); + evidence.recordCriticalPoint({ + id: 'CP2', + requirement: 'Default WebMCP ACP Chat surface exposes only lower-snake safe metadata tools.', + status: 'pass', + evidence: [layoutProof].filter(Boolean) as string[], + }); + evidence.recordCriticalPoint({ + id: 'CP3', + requirement: 'Session and permission state tools return successful metadata-only responses.', + status: 'pass', + evidence: [layoutProof].filter(Boolean) as string[], + }); + await evidence.finalize({ + scenarioVerdict: 'PASS', + hardeningVerdict: 'CONVERT', + runtime: { + url: page.url(), + viewport: page.viewportSize(), + browserSurface: 'Playwright Chromium', + }, + }); + }); +}); diff --git a/tools/playwright/src/tests/acp-chat.test.ts b/tools/playwright/src/tests/acp-chat.test.ts new file mode 100644 index 0000000000..82ea940a06 --- /dev/null +++ b/tools/playwright/src/tests/acp-chat.test.ts @@ -0,0 +1,114 @@ +// Source: test/bdd/acp-chat.scenario.md + +import path from 'path'; + +import { expect } from '@playwright/test'; + +import { OpenSumiApp } from '../app'; +import { OpenSumiWorkspace } from '../workspace'; + +import test, { page } from './hooks'; +import { + aiNativeWorkbenchUrl, + waitForAcpChatReady, + waitForWorkbenchReady, + writeAiNativePanelLayoutSettings, +} from './utils/acp-bdd-fixture'; + +let app: OpenSumiApp; +let workspace: OpenSumiWorkspace; + +test.describe('ACP Chat default WebMCP surface', () => { + test.beforeAll(async () => { + workspace = new OpenSumiWorkspace([path.resolve(__dirname, '../../src/tests/workspaces/default')]); + await workspace.initWorksapce(); + await writeAiNativePanelLayoutSettings(workspace.workspace.codeUri.fsPath, 'agentic'); + app = new OpenSumiApp(page); + await page.goto(aiNativeWorkbenchUrl(workspace.workspace.codeUri.fsPath)); + await waitForWorkbenchReady(page); + }); + + test.afterAll(() => { + app.dispose(); + workspace.dispose(); + }); + + test('opens ACP chat and exposes safe metadata-only state tools', async () => { + await page.waitForFunction(() => Boolean((navigator as any).modelContext?.executeTool)); + + const result = await page.evaluate(async () => { + const modelContext = (navigator as any).modelContext; + const tools = await modelContext.getTools(); + const toolNames = tools.map((tool: { name: string }) => tool.name).sort(); + const legacyNames = [ + 'acp_sendMessage', + 'acp_createSession', + 'acp_switchSession', + 'acp_clearSession', + 'acp_cancelRequest', + 'acp_handlePermissionDialog', + 'acp_chat_getSessionState', + 'acp_chat_getPermissionState', + 'acp_chat_showChatView', + ]; + + const show = await modelContext.executeTool('acp_chat_show_chat_view', {}); + const sessionState = await modelContext.executeTool('acp_chat_get_session_state', {}); + const permissionState = await modelContext.executeTool('acp_chat_get_permission_state', {}); + let camelCaseResult: any; + try { + camelCaseResult = await modelContext.executeTool('acp_chat_getSessionState', {}); + } catch (error) { + camelCaseResult = { success: false, error: String(error) }; + } + + return { + toolNames, + acpTools: toolNames.filter((name: string) => name.startsWith('acp_chat')), + forbiddenTools: toolNames.filter((name: string) => legacyNames.includes(name) || name.startsWith('_opensumi/')), + show, + sessionState, + permissionState, + camelCaseResult, + }; + }); + await waitForAcpChatReady(page); + await expect(page.locator('.AI-Chat-slot')).toBeVisible(); + + expect(result.acpTools).toEqual([ + 'acp_chat_get_permission_state', + 'acp_chat_get_session_state', + 'acp_chat_show_chat_view', + ]); + expect(result.forbiddenTools).toEqual([]); + expect(result.show).toMatchObject({ success: true, result: { shown: true } }); + expect(result.camelCaseResult.success).toBe(false); + + expect(result.sessionState.success).toBe(true); + if (result.sessionState.result.active) { + const session = result.sessionState.result.session; + expect(Object.keys(session).sort()).toEqual( + expect.arrayContaining([ + 'createdAt', + 'hasPendingPermission', + 'historyMessageCount', + 'modelId', + 'rawSessionId', + 'requestCount', + 'sessionId', + 'slicedMessageCount', + 'threadStatus', + 'title', + ]), + ); + expect(session.messages).toBeUndefined(); + expect(session.content).toBeUndefined(); + expect(session.toolCallResults).toBeUndefined(); + } + + expect(result.permissionState.success).toBe(true); + expect(Object.keys(result.permissionState.result).sort()).toEqual( + expect.arrayContaining(['activeDialogCount', 'activeSessionId', 'pendingCountExcludingActive']), + ); + }); +}); diff --git a/tools/playwright/src/tests/available-commands.test.ts b/tools/playwright/src/tests/available-commands.test.ts new file mode 100644 index 0000000000..93413ac351 --- /dev/null +++ b/tools/playwright/src/tests/available-commands.test.ts @@ -0,0 +1,116 @@ +// Source: test/bdd/available-commands.scenario.md + +import { expect } from '@playwright/test'; + +import test, { page } from './hooks'; +import { + ACP_BDD_FIXTURE_HOOK_TIMEOUT_MS, + type AcpBddFixtureRuntime, + loadAcpBddFixtureWorkbench, +} from './utils/acp-bdd-fixture'; +import { createBddEvidence } from './utils/bdd-evidence'; + +let runtime: AcpBddFixtureRuntime; + +test.describe('Available commands deterministic fixture surface', () => { + test.setTimeout(ACP_BDD_FIXTURE_HOOK_TIMEOUT_MS); + + test.beforeAll(async () => { + test.setTimeout(ACP_BDD_FIXTURE_HOOK_TIMEOUT_MS); + runtime = await loadAcpBddFixtureWorkbench(page, { + fixture: 'stream-rich', + profile: 'interactive', + delayMs: 5, + showChatView: true, + ensureAgenticLayout: true, + viewport: { width: 1600, height: 900 }, + }); + }); + + test.afterAll(async () => { + await runtime?.dispose(); + }); + + test('exposes stable mock ACP command metadata through WebMCP', async ({ browser: _browser }, testInfo) => { + const evidence = createBddEvidence(testInfo, 'available-commands', { + sourceScenario: 'test/bdd/available-commands.scenario.md', + profile: 'interactive', + executionMode: 'deterministic-fixture', + hardeningVerdict: 'CONVERT', + }); + + await expect + .poll( + () => + page.evaluate(async () => { + const modelContext = (navigator as any).modelContext; + if (!modelContext?.executeTool) { + return 0; + } + const result = await modelContext.executeTool('acp_chat_get_available_commands', {}); + return result?.success === true ? result.result?.commands?.length || 0 : 0; + }), + { timeout: 30_000 }, + ) + .toBeGreaterThanOrEqual(3); + + const proof = await page.evaluate(async () => { + const modelContext = (navigator as any).modelContext; + const tools = await modelContext.getTools(); + const commandResult = await modelContext.executeTool('acp_chat_get_available_commands', {}); + return { + acpTools: tools + .map((tool: { name: string }) => tool.name) + .filter((name: string) => name.startsWith('acp_chat')), + commandResult, + }; + }); + + const commandProof = await evidence.saveJson( + '01-available-commands', + proof, + 'deterministic ACP command metadata returned through WebMCP', + ); + + expect(proof.acpTools).toContain('acp_chat_get_available_commands'); + expect(proof.commandResult).toMatchObject({ + success: true, + result: { + total: 3, + }, + }); + expect(proof.commandResult.result.commands).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'bdd_echo', description: expect.any(String) }), + expect.objectContaining({ name: 'bdd_plan', description: expect.any(String) }), + expect.objectContaining({ name: 'bdd_permission', description: expect.any(String) }), + ]), + ); + expect(JSON.stringify(proof.commandResult)).not.toContain('BDD_ASSISTANT'); + expect(JSON.stringify(proof.commandResult)).not.toContain('toolCall'); + + evidence.recordCriticalPoint({ + id: 'CP1', + requirement: 'Interactive profile exposes acp_chat_get_available_commands.', + status: 'pass', + evidence: [commandProof].filter(Boolean) as string[], + }); + evidence.recordCriticalPoint({ + id: 'CP2', + requirement: 'The stream-rich fixture provides stable command metadata without chat content leakage.', + status: 'pass', + evidence: [commandProof].filter(Boolean) as string[], + }); + await evidence.finalize({ + scenarioVerdict: 'PASS', + hardeningVerdict: 'CONVERT', + runtime: { + url: page.url(), + viewport: page.viewportSize(), + browserSurface: 'Playwright Chromium', + fixture: runtime.fixture, + profile: runtime.profile, + }, + }); + }); +}); diff --git a/tools/playwright/src/tests/debug.test.ts b/tools/playwright/src/tests/debug.test.ts index 66113399f3..5e02a3f9d7 100644 --- a/tools/playwright/src/tests/debug.test.ts +++ b/tools/playwright/src/tests/debug.test.ts @@ -18,6 +18,61 @@ let debugView: OpenSumiDebugView; let editor: OpenSumiTextEditor; let workspace: OpenSumiWorkspace; +const DEBUG_BREAKPOINT_LINE = 6; + +async function ensureBreakpointWidget(lineNumber = DEBUG_BREAKPOINT_LINE) { + const glyphMarginModel = await editor.getGlyphMarginModel(); + const existingWidget = await glyphMarginModel.getGlyphMarginWidgets(lineNumber); + if (existingWidget && (await glyphMarginModel.hasBreakpoint(existingWidget))) { + return { glyphMarginModel, breakpointWidget: existingWidget }; + } + + const overlay = await glyphMarginModel.getOverlay(lineNumber); + expect(overlay).toBeDefined(); + await overlay!.click({ position: { x: 9, y: 9 }, force: true }); + + await expect + .poll( + async () => { + const breakpointWidget = await glyphMarginModel.getGlyphMarginWidgets(lineNumber); + return breakpointWidget ? await glyphMarginModel.hasBreakpoint(breakpointWidget) : false; + }, + { timeout: 5000 }, + ) + .toBeTruthy(); + + const breakpointWidget = await glyphMarginModel.getGlyphMarginWidgets(lineNumber); + expect(breakpointWidget).toBeDefined(); + return { glyphMarginModel, breakpointWidget: breakpointWidget! }; +} + +async function expectTopStackFrame(glyphMarginModel: Awaited>) { + await expect + .poll( + async () => { + const topStackFrameNode = await glyphMarginModel.getGlyphMarginWidgets(DEBUG_BREAKPOINT_LINE); + return topStackFrameNode ? await glyphMarginModel.hasTopStackFrame(topStackFrameNode) : false; + }, + { timeout: 10_000 }, + ) + .toBeTruthy(); +} + +async function expectTopStackFrameLine( + glyphMarginModel: Awaited>, +) { + const overlaysModel = await editor.getOverlaysModel(); + await expect + .poll( + async () => { + const viewOverlay = await overlaysModel.getOverlay(DEBUG_BREAKPOINT_LINE); + return viewOverlay ? await glyphMarginModel.hasTopStackFrameLine(viewOverlay) : false; + }, + { timeout: 10_000 }, + ) + .toBeTruthy(); +} + test.describe('OpenSumi Debug', () => { test.beforeAll(async () => { workspace = new OpenSumiWorkspace([path.resolve(__dirname, '../../src/tests/workspaces/debug')]); @@ -33,17 +88,8 @@ test.describe('OpenSumi Debug', () => { test('Debug breakpoint editor glyph margin should be worked', async () => { editor = await app.openEditor(OpenSumiTextEditor, explorer, 'index.js', false); - const glyphMarginModel = await editor.getGlyphMarginModel(); - const overlay = await glyphMarginModel.getOverlay(6); - await overlay?.click({ position: { x: 9, y: 9 }, force: true }); - await app.page.waitForTimeout(1000); - // 此时元素 dom 结构已经改变,需要重新获取 - const marginWidgets = await glyphMarginModel.getGlyphMarginWidgets(6); - expect(marginWidgets).toBeDefined(); - if (!marginWidgets) { - return; - } - expect(await glyphMarginModel.hasBreakpoint(marginWidgets!)).toBeTruthy(); + const { glyphMarginModel, breakpointWidget } = await ensureBreakpointWidget(); + expect(await glyphMarginModel.hasBreakpoint(breakpointWidget)).toBeTruthy(); await editor.close(); }); @@ -52,37 +98,13 @@ test.describe('OpenSumi Debug', () => { await app.page.waitForTimeout(1000); debugView = await app.open(OpenSumiDebugView); - const glyphMarginModel = await editor.getGlyphMarginModel(); - let glyphOverlay = await glyphMarginModel.getGlyphMarginWidgets(6); - expect(glyphOverlay).toBeDefined(); - if (!glyphOverlay) { - return; - } - const isClicked = await glyphMarginModel.hasBreakpoint(glyphOverlay); - if (!isClicked) { - await glyphOverlay?.click({ position: { x: 9, y: 9 }, force: true }); - await app.page.waitForTimeout(1000); - } + const { glyphMarginModel } = await ensureBreakpointWidget(); await debugView.start(); - await app.page.waitForTimeout(2000); - - const topStackFrameNode = await glyphMarginModel.getGlyphMarginWidgets(6); - expect(topStackFrameNode).toBeDefined(); - if (!topStackFrameNode) { - return; - } - expect(await glyphMarginModel.hasTopStackFrame(topStackFrameNode)).toBeTruthy(); - - const overlaysModel = await editor.getOverlaysModel(); - const viewOverlay = await overlaysModel.getOverlay(6); - // get editor line 6 - expect(viewOverlay).toBeDefined(); - if (!viewOverlay) { - return; - } - expect(await glyphMarginModel.hasTopStackFrameLine(viewOverlay)).toBeTruthy(); - await editor.close(); + await expectTopStackFrame(glyphMarginModel); + await expectTopStackFrameLine(glyphMarginModel); + // Debug toolbar floats over editor tabs, use force click to close + await editor.close({ force: true }); await debugView.stop(); await page.waitForTimeout(1000); }); @@ -92,18 +114,7 @@ test.describe('OpenSumi Debug', () => { await app.page.waitForTimeout(1000); debugView = await app.open(OpenSumiDebugView); - const glyphMarginModel = await editor.getGlyphMarginModel(); - // get editor line 6 - const glyphOverlay = await glyphMarginModel.getOverlay(6); - expect(glyphOverlay).toBeDefined(); - if (!glyphOverlay) { - return; - } - const isClicked = await glyphMarginModel.hasBreakpoint(glyphOverlay); - if (!isClicked) { - await glyphOverlay?.click({ position: { x: 9, y: 9 }, force: true }); - await app.page.waitForTimeout(1000); - } + await ensureBreakpointWidget(); await debugView.start(); await app.page.waitForTimeout(2000); @@ -118,7 +129,8 @@ test.describe('OpenSumi Debug', () => { const text = (await page.evaluate('navigator.clipboard.readText()')) as string; expect(text.includes('Debugger attached.')).toBeTruthy(); - await editor.close(); + // Debug toolbar floats over editor tabs, use force click to close + await editor.close({ force: true }); await debugView.stop(); await page.waitForTimeout(1000); }); @@ -130,36 +142,11 @@ test.describe('OpenSumi Debug', () => { debugView = await app.open(OpenSumiDebugView); const terminal = await app.open(OpenSumiTerminalView); await terminal.createTerminalByType('Javascript Debug Terminal'); - const glyphMarginModel = await editor.getGlyphMarginModel(); - let glyphOverlay = await glyphMarginModel.getOverlay(6); - expect(glyphOverlay).toBeDefined(); - if (!glyphOverlay) { - return; - } - const isClicked = await glyphMarginModel.hasBreakpoint(glyphOverlay); - if (!isClicked) { - await glyphOverlay?.click({ position: { x: 9, y: 9 }, force: true }); - await app.page.waitForTimeout(1000); - } + const { glyphMarginModel } = await ensureBreakpointWidget(); await terminal.sendText('node index.js'); - await app.page.waitForTimeout(2000); - - // get editor line 6 - const glyphMarginWidget = await glyphMarginModel.getGlyphMarginWidgets(6); - expect(glyphMarginWidget).toBeDefined(); - if (!glyphMarginWidget) { - return; - } - expect(await glyphMarginModel.hasTopStackFrame(glyphMarginWidget)).toBeTruthy(); - - const overlaysModel = await editor.getOverlaysModel(); - const viewOverlay = await overlaysModel.getOverlay(6); - expect(viewOverlay).toBeDefined(); - if (!viewOverlay) { - return; - } - expect(await glyphMarginModel.hasTopStackFrameLine(viewOverlay)).toBeTruthy(); + await expectTopStackFrame(glyphMarginModel); + await expectTopStackFrameLine(glyphMarginModel); await debugView.stop(); await page.waitForTimeout(1000); }); diff --git a/tools/playwright/src/tests/permission-dialog.test.ts b/tools/playwright/src/tests/permission-dialog.test.ts new file mode 100644 index 0000000000..73e5b0f6ad --- /dev/null +++ b/tools/playwright/src/tests/permission-dialog.test.ts @@ -0,0 +1,367 @@ +// Source: test/bdd/permission-dialog.scenario.md +// Source: test/bdd/acp-chat-agentic-permission-during-send.scenario.md +// Source: test/bdd/acp-permission-routing.scenario.md + +import { expect } from '@playwright/test'; + +import test, { page } from './hooks'; +import { + ACP_BDD_FIXTURE_HOOK_TIMEOUT_MS, + type AcpBddFixtureRuntime, + loadAcpBddFixtureWorkbench, +} from './utils/acp-bdd-fixture'; +import { createBddEvidence } from './utils/bdd-evidence'; + +const PERMISSION_DIALOG_SELECTOR = '[data-testid="acp-permission-dialog"]'; +const PERMISSION_CLOSE_SELECTOR = '[data-testid="acp-permission-dialog-close"]'; +const PERMISSION_REJECT_SELECTOR = '[data-testid^="acp-permission-dialog-option-"][data-option-kind^="reject"]'; +const PERMISSION_TITLE_SELECTOR = '[data-testid="acp-permission-dialog-title"]'; +const PERMISSION_OPTIONS_SELECTOR = '[data-testid="acp-permission-dialog-options"]'; +const PERMISSION_SOURCE_SCENARIOS = [ + 'test/bdd/permission-dialog.scenario.md', + 'test/bdd/acp-chat-agentic-permission-during-send.scenario.md', + 'test/bdd/acp-permission-routing.scenario.md', +]; +const FORBIDDEN_PERMISSION_TOOL_NAMES = [ + 'acp_handlePermissionDialog', + 'acp_chat_handlePermissionDialog', + 'acp_chat_handle_permission_dialog', +]; + +let runtime: AcpBddFixtureRuntime | undefined; + +interface PermissionStateResult { + success: boolean; + result: { + activeDialogCount: number; + activeSessionId?: string | null; + pendingCountExcludingActive: number; + }; +} + +async function loadPermissionFixtureWorkbench() { + runtime = await loadAcpBddFixtureWorkbench(page, { + fixture: 'permission', + profile: 'full', + delayMs: 5, + showChatView: true, + ensureAgenticLayout: true, + viewport: { width: 1800, height: 1000 }, + }); + await expect(page.getByRole('heading', { name: 'AI Assistant' })).toBeVisible(); +} + +function chatInput() { + return page.locator('.AI-Chat-slot [contenteditable="true"]').last(); +} + +function permissionDialog() { + return page.locator(PERMISSION_DIALOG_SELECTOR).first(); +} + +async function readToolNames(): Promise { + return page.evaluate(async () => { + const tools = await (navigator as any).modelContext.getTools(); + return tools.map((tool: { name: string }) => tool.name).sort(); + }); +} + +async function readPermissionState(): Promise { + return page.evaluate(async () => (navigator as any).modelContext.executeTool('acp_chat_get_permission_state', {})); +} + +function expectPermissionStateMetadataOnly(state: PermissionStateResult) { + expect(state.success).toBe(true); + expect(Object.keys(state.result).sort()).toEqual([ + 'activeDialogCount', + 'activeSessionId', + 'pendingCountExcludingActive', + ]); + expect(typeof state.result.activeDialogCount).toBe('number'); + expect(typeof state.result.pendingCountExcludingActive).toBe('number'); + expect( + state.result.activeSessionId === undefined || + state.result.activeSessionId === null || + typeof state.result.activeSessionId === 'string', + ).toBe(true); +} + +function expectNoPermissionDecisionTools(toolNames: string[]) { + expect(toolNames).toContain('acp_chat_get_permission_state'); + expect(toolNames).toContain('acp_chat_read_session_messages'); + expect(toolNames.filter((name) => FORBIDDEN_PERMISSION_TOOL_NAMES.includes(name))).toEqual([]); + expect(toolNames.filter((name) => name.startsWith('_opensumi/acp_chat'))).toEqual([]); + expect(toolNames.filter((name) => /permission/i.test(name))).toEqual(['acp_chat_get_permission_state']); +} + +async function sendPermissionPrompt(prompt: string) { + const input = chatInput(); + await expect(input).toBeVisible(); + await expect(input).toBeEditable(); + await input.click(); + await page.keyboard.type(prompt); + await page.getByRole('button', { name: 'Send' }).click(); +} + +async function waitForPendingPermission(): Promise { + await expect(permissionDialog()).toBeVisible({ timeout: 30_000 }); + await expect(page.locator(PERMISSION_TITLE_SELECTOR)).toBeVisible(); + await expect(page.locator(PERMISSION_OPTIONS_SELECTOR)).toBeVisible(); + + await expect + .poll(async () => (await readPermissionState()).result.activeDialogCount, { timeout: 30_000 }) + .toBeGreaterThanOrEqual(1); + + const pendingState = await readPermissionState(); + expectPermissionStateMetadataOnly(pendingState); + expect(pendingState.result.activeDialogCount).toBeGreaterThanOrEqual(1); + expect(pendingState.result.activeSessionId).toEqual(expect.any(String)); + + const titleText = (await page.locator(PERMISSION_TITLE_SELECTOR).textContent()) || ''; + expect(titleText.trim().length).toBeGreaterThan(0); + + await expect( + page.locator(`[data-testid="acp-permission-pending-acp:${pendingState.result.activeSessionId}"]`), + ).toBeVisible({ timeout: 10_000 }); + + return pendingState; +} + +async function waitForPermissionDismissed() { + await expect(permissionDialog()).toBeHidden({ timeout: 30_000 }); + await expect.poll(async () => (await readPermissionState()).result.activeDialogCount, { timeout: 30_000 }).toBe(0); + await expect + .poll(async () => (await readPermissionState()).result.pendingCountExcludingActive, { timeout: 30_000 }) + .toBe(0); + await expect(chatInput()).toBeVisible({ timeout: 30_000 }); + await expect(chatInput()).toBeEditable({ timeout: 30_000 }); + await expect(page.getByRole('button', { name: 'Send' })).toBeVisible({ timeout: 30_000 }); +} + +async function readVisiblePermissionProof() { + const state = await readPermissionState(); + const close = page.locator(PERMISSION_CLOSE_SELECTOR); + const reject = page.locator(PERMISSION_REJECT_SELECTOR).first(); + const titleText = (await page.locator(PERMISSION_TITLE_SELECTOR).textContent()) || ''; + + return { + permissionState: state.result, + dialogVisible: await permissionDialog().isVisible(), + titleHasVisibleText: titleText.trim().length > 0, + closeVisible: await close.isVisible(), + rejectVisible: await reject.isVisible(), + rejectOptionCount: await page.locator(PERMISSION_REJECT_SELECTOR).count(), + activeSessionBadgeVisible: state.result.activeSessionId + ? await page.locator(`[data-testid="acp-permission-pending-acp:${state.result.activeSessionId}"]`).isVisible() + : false, + }; +} + +test.describe('Permission dialog deterministic observability', () => { + test.setTimeout(ACP_BDD_FIXTURE_HOOK_TIMEOUT_MS); + + test.beforeEach(async () => { + test.setTimeout(ACP_BDD_FIXTURE_HOOK_TIMEOUT_MS); + await loadPermissionFixtureWorkbench(); + }); + + test.afterEach(async () => { + await runtime?.dispose(); + runtime = undefined; + }); + + test('Permission dialog closes through the visible close control without ACP decision tools', async ({ + browser: _browser, + }, testInfo) => { + const evidence = createBddEvidence(testInfo, 'permission-dialog-close', { + sourceScenario: PERMISSION_SOURCE_SCENARIOS.join(', '), + profile: 'full', + executionMode: 'deterministic-fixture', + hardeningVerdict: 'CONVERT', + }); + + const toolNames = await readToolNames(); + expectNoPermissionDecisionTools(toolNames); + const baseline = await readPermissionState(); + expectPermissionStateMetadataOnly(baseline); + expect(baseline.result.activeDialogCount).toBe(0); + + await sendPermissionPrompt('BDD permission close path'); + const pendingState = await waitForPendingPermission(); + const pendingProof = await readVisiblePermissionProof(); + expect(pendingProof).toMatchObject({ + dialogVisible: true, + titleHasVisibleText: true, + closeVisible: true, + activeSessionBadgeVisible: true, + }); + + await page.locator(PERMISSION_CLOSE_SELECTOR).click(); + await waitForPermissionDismissed(); + const afterDismiss = await readPermissionState(); + expectPermissionStateMetadataOnly(afterDismiss); + expect(afterDismiss.result.activeDialogCount).toBe(baseline.result.activeDialogCount); + + const proof = await evidence.saveJson( + '01-permission-close-proof', + { + toolNames: toolNames.filter((name) => name.startsWith('acp_chat')), + baseline: baseline.result, + pending: pendingState.result, + pendingProof, + afterDismiss: afterDismiss.result, + }, + 'metadata-only permission state and visible close dismissal proof', + ); + const screenshot = await evidence.captureScreenshot(page, '02-permission-close-after-dismiss', 'chat after close'); + + evidence.recordCriticalPoint({ + id: 'CP1', + requirement: 'Full-profile WebMCP exposes permission state but no ACP permission decision tool.', + status: 'pass', + evidence: [proof].filter(Boolean) as string[], + }); + evidence.recordCriticalPoint({ + id: 'CP2', + requirement: 'The permission fixture creates a visible active-session dialog and pending badge/count metadata.', + status: 'pass', + evidence: [proof].filter(Boolean) as string[], + }); + evidence.recordCriticalPoint({ + id: 'CP3', + requirement: 'A visible close control dismisses the permission dialog and restores editable input.', + status: 'pass', + evidence: [proof, screenshot].filter(Boolean) as string[], + }); + evidence.recordCriticalPoint({ + id: 'CP4', + requirement: + 'The permission-routing visible lifecycle observes active dialog counts and closes through browser UI, not ACP decision tools.', + status: 'pass', + evidence: [proof, screenshot].filter(Boolean) as string[], + }); + await evidence.finalize({ + scenarioVerdict: 'PASS', + hardeningVerdict: 'CONVERT', + runtime: { + url: page.url(), + viewport: page.viewportSize(), + browserSurface: 'Playwright Chromium', + fixture: 'permission', + profile: 'full', + }, + }); + }); + + test('Permission dialog rejects through the visible reject control without ACP decision tools', async ({ + browser: _browser, + }, testInfo) => { + const evidence = createBddEvidence(testInfo, 'permission-dialog-reject', { + sourceScenario: PERMISSION_SOURCE_SCENARIOS.join(', '), + profile: 'full', + executionMode: 'deterministic-fixture', + hardeningVerdict: 'CONVERT', + }); + + const toolNames = await readToolNames(); + expectNoPermissionDecisionTools(toolNames); + const baseline = await readPermissionState(); + expectPermissionStateMetadataOnly(baseline); + expect(baseline.result.activeDialogCount).toBe(0); + + await sendPermissionPrompt('BDD permission reject path'); + const pendingState = await waitForPendingPermission(); + const pendingProof = await readVisiblePermissionProof(); + expect(pendingProof).toMatchObject({ + dialogVisible: true, + titleHasVisibleText: true, + rejectVisible: true, + activeSessionBadgeVisible: true, + }); + expect(pendingProof.rejectOptionCount).toBeGreaterThanOrEqual(1); + + await page.locator(PERMISSION_REJECT_SELECTOR).first().click(); + await waitForPermissionDismissed(); + const afterDismiss = await readPermissionState(); + expectPermissionStateMetadataOnly(afterDismiss); + expect(afterDismiss.result.activeDialogCount).toBe(baseline.result.activeDialogCount); + + const sessionState = await page.evaluate(async () => + (navigator as any).modelContext.executeTool('acp_chat_get_session_state', {}), + ); + expect(sessionState.success).toBe(true); + if (sessionState.result.active) { + expect(sessionState.result.session.hasPendingPermission).toBe(false); + expect(sessionState.result.session.messages).toBeUndefined(); + expect(sessionState.result.session.content).toBeUndefined(); + expect(sessionState.result.session.toolCallResults).toBeUndefined(); + } + + const proof = await evidence.saveJson( + '01-permission-reject-proof', + { + toolNames: toolNames.filter((name) => name.startsWith('acp_chat')), + baseline: baseline.result, + pending: pendingState.result, + pendingProof, + afterDismiss: afterDismiss.result, + sessionState: sessionState.result.active + ? { + active: true, + session: { + sessionId: sessionState.result.session.sessionId, + rawSessionId: sessionState.result.session.rawSessionId, + hasPendingPermission: sessionState.result.session.hasPendingPermission, + requestCount: sessionState.result.session.requestCount, + historyMessageCount: sessionState.result.session.historyMessageCount, + threadStatus: sessionState.result.session.threadStatus, + }, + } + : { active: false }, + }, + 'metadata-only permission state and visible reject dismissal proof', + ); + const screenshot = await evidence.captureScreenshot( + page, + '02-permission-reject-after-dismiss', + 'chat after reject', + ); + + evidence.recordCriticalPoint({ + id: 'CP1', + requirement: 'The permission fixture exposes pending state through acp_chat_get_permission_state only.', + status: 'pass', + evidence: [proof].filter(Boolean) as string[], + }); + evidence.recordCriticalPoint({ + id: 'CP2', + requirement: 'A visible reject control dismisses the permission dialog and clears active pending state.', + status: 'pass', + evidence: [proof, screenshot].filter(Boolean) as string[], + }); + evidence.recordCriticalPoint({ + id: 'CP3', + requirement: 'Session state remains metadata-only and recoverable after UI rejection.', + status: 'pass', + evidence: [proof].filter(Boolean) as string[], + }); + evidence.recordCriticalPoint({ + id: 'CP4', + requirement: + 'The permission-routing visible reject lifecycle observes pending counts and dismisses through browser UI, not ACP decision tools.', + status: 'pass', + evidence: [proof, screenshot].filter(Boolean) as string[], + }); + await evidence.finalize({ + scenarioVerdict: 'PASS', + hardeningVerdict: 'CONVERT', + runtime: { + url: page.url(), + viewport: page.viewportSize(), + browserSurface: 'Playwright Chromium', + fixture: 'permission', + profile: 'full', + }, + }); + }); +}); diff --git a/tools/playwright/src/tests/utils/acp-bdd-fixture.ts b/tools/playwright/src/tests/utils/acp-bdd-fixture.ts new file mode 100644 index 0000000000..f49ae3cd4b --- /dev/null +++ b/tools/playwright/src/tests/utils/acp-bdd-fixture.ts @@ -0,0 +1,409 @@ +import { promises as fs } from 'fs'; +import os from 'os'; +import path from 'path'; + +import { type Page } from '@playwright/test'; + +import { OpenSumiApp } from '../../app'; +import { OpenSumiWorkspace } from '../../workspace'; + +export const ACP_BDD_FIXTURES = [ + 'stream-rich', + 'long-stream', + 'permission', + 'send-failure', + 'create-failure', + 'load-failure', + 'auth-required', + 'config-failure', + 'process-exit', + 'history', +] as const; + +export type AcpBddFixture = (typeof ACP_BDD_FIXTURES)[number]; +export type WebMcpProfile = 'default' | 'interactive' | 'full'; +export type AiNativePanelLayout = 'classic' | 'agentic'; + +export const ACP_BDD_BACKEND_READY_FAILURE_QUERY_PARAM = 'acpBddBackendReadyFailure'; +export const ACP_BDD_BACKEND_READY_FAILURE_QUERY_VALUE = 'reject'; + +export interface AcpBddFixtureOptions { + fixture: AcpBddFixture; + profile?: WebMcpProfile; + panelLayout?: AiNativePanelLayout; + workspaceFiles?: string[]; + delayMs?: number; + longStreamTicks?: number; + sessionPrefix?: string; + agentType?: string; + showChatView?: boolean; + ensureAgenticLayout?: boolean; + forceAcpBackendReadyFailure?: boolean; + waitForModelContext?: boolean; + viewport?: { + width: number; + height: number; + }; +} + +export interface AcpBddFixtureRuntime { + app: OpenSumiApp; + workspace: OpenSumiWorkspace; + fixture: AcpBddFixture; + profile: WebMcpProfile; + workspaceDir: string; + url: string; + dispose(): Promise; +} + +export interface AcpBddFixturePass extends AcpBddFixtureOptions { + name?: string; +} + +export const ACP_BDD_REPO_ROOT = path.resolve(__dirname, '../../../../..'); +export const ACP_BDD_DEFAULT_WORKSPACE = path.join(ACP_BDD_REPO_ROOT, 'tools/playwright/src/tests/workspaces/default'); +export const ACP_BDD_MOCK_ACP_AGENT = path.join(ACP_BDD_REPO_ROOT, 'test/bdd/fixtures/acp-agent/mock-acp-agent.mjs'); +const DEFAULT_AGENT_TYPE = 'claude-agent-acp'; +const LOCK_ROOT = path.join(os.tmpdir(), 'opensumi-bdd-acp-fixture-runtime'); +const LOCK_STALE_MS = 5 * 60 * 1000; +const LOCK_TIMEOUT_MS = 90 * 1000; +export const ACP_BDD_FIXTURE_HOOK_TIMEOUT_MS = 120 * 1000; +const MODEL_CONTEXT_TIMEOUT_MS = 60 * 1000; +const ACP_CHAT_READY_TIMEOUT_MS = 60 * 1000; +const EXPLORER_VIEW_READY_TIMEOUT_MS = 30 * 1000; +const AI_NATIVE_PANEL_LAYOUT_SETTING_ID = 'ai.native.panelLayout'; +let nextRuntimeId = 1; + +function assertSupportedFixture(fixture: string): asserts fixture is AcpBddFixture { + if (!(ACP_BDD_FIXTURES as readonly string[]).includes(fixture)) { + throw new Error(`Unsupported ACP BDD fixture: ${fixture}`); + } +} + +function createSessionPrefix(): string { + return `bdd-session-${process.pid}-${Date.now()}-${nextRuntimeId++}`; +} + +function withRuntimeDefaults(options: AcpBddFixtureOptions): AcpBddFixtureOptions { + return { + ...options, + sessionPrefix: options.sessionPrefix || createSessionPrefix(), + }; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function acquireRuntimeLock(): Promise<() => Promise> { + const lockDir = path.join(LOCK_ROOT, 'runtime.lock'); + const startedAt = Date.now(); + + await fs.mkdir(LOCK_ROOT, { recursive: true }); + + while (Date.now() - startedAt < LOCK_TIMEOUT_MS) { + try { + await fs.mkdir(lockDir); + await fs.writeFile( + path.join(lockDir, 'owner.json'), + `${JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }, null, 2)}\n`, + 'utf8', + ); + return async () => { + await fs.rm(lockDir, { recursive: true, force: true }); + }; + } catch (error: any) { + if (error?.code !== 'EEXIST') { + throw error; + } + + try { + const stat = await fs.stat(lockDir); + if (Date.now() - stat.mtimeMs > LOCK_STALE_MS) { + await fs.rm(lockDir, { recursive: true, force: true }); + continue; + } + } catch (statError: any) { + if (statError?.code !== 'ENOENT') { + throw statError; + } + } + + await sleep(250); + } + } + + throw new Error(`Timed out waiting for ACP BDD fixture runtime lock: ${lockDir}`); +} + +async function readJsonObject(filePath: string): Promise> { + try { + const content = await fs.readFile(filePath, 'utf8'); + const parsed = JSON.parse(content); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}; + } catch (error: any) { + if (error?.code === 'ENOENT') { + return {}; + } + throw error; + } +} + +export function getMockAcpAgentCommand(options: AcpBddFixtureOptions) { + assertSupportedFixture(options.fixture); + + const args = [ACP_BDD_MOCK_ACP_AGENT, `--fixture=${options.fixture}`]; + const env: Record = { + OPENSUMI_ACP_BDD_FIXTURE: options.fixture, + }; + + if (options.delayMs !== undefined) { + args.push(`--delay-ms=${options.delayMs}`); + env.OPENSUMI_ACP_BDD_DELAY_MS = String(options.delayMs); + } + if (options.longStreamTicks !== undefined) { + args.push(`--long-stream-ticks=${options.longStreamTicks}`); + env.OPENSUMI_ACP_BDD_LONG_STREAM_TICKS = String(options.longStreamTicks); + } + if (options.sessionPrefix) { + args.push(`--session-prefix=${options.sessionPrefix}`); + env.OPENSUMI_ACP_BDD_SESSION_PREFIX = options.sessionPrefix; + } + + return { + command: process.execPath, + args, + cwd: ACP_BDD_REPO_ROOT, + env, + streaming: true, + description: `OpenSumi BDD mock ACP agent (${options.fixture})`, + }; +} + +export async function writeMockAcpAgentSettings(workspaceDir: string, options: AcpBddFixtureOptions): Promise { + const settingsDir = path.join(workspaceDir, '.sumi'); + const settingsPath = path.join(settingsDir, 'settings.json'); + const agentType = options.agentType || DEFAULT_AGENT_TYPE; + const settings = await readJsonObject(settingsPath); + const existingAgents = settings['ai-native.acp.agents']; + const agents = + existingAgents && typeof existingAgents === 'object' && !Array.isArray(existingAgents) + ? { ...(existingAgents as Record) } + : {}; + + agents[agentType] = getMockAcpAgentCommand({ ...options, agentType }); + settings['ai.native.agent.defaultType'] = agentType; + settings['ai-native.acp.agents'] = agents; + + await fs.mkdir(settingsDir, { recursive: true }); + await fs.writeFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, 'utf8'); +} + +export async function writeAiNativePanelLayoutSettings( + workspaceDir: string, + panelLayout: AiNativePanelLayout, +): Promise { + const settingsDir = path.join(workspaceDir, '.sumi'); + const settingsPath = path.join(settingsDir, 'settings.json'); + const settings = await readJsonObject(settingsPath); + + settings[AI_NATIVE_PANEL_LAYOUT_SETTING_ID] = panelLayout; + + await fs.mkdir(settingsDir, { recursive: true }); + await fs.writeFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, 'utf8'); +} + +export async function waitForWorkbenchReady(page: Page): Promise { + await page.waitForSelector('.loading_indicator', { state: 'detached' }); + await page.waitForSelector('#main'); + await page.waitForFunction(() => { + const text = document.body.innerText || ''; + const shellReady = + document.readyState === 'complete' && + !!document.querySelector('#main') && + !document.querySelector('.loading_indicator'); + const workbenchVisible = + text.includes('EXPLORER') || + text.includes('Agentic') || + text.includes('editor.js') || + !!document.querySelector('.monaco-editor'); + return shellReady && workbenchVisible; + }); +} + +export async function ensureAgenticLayout(page: Page): Promise { + await page.waitForFunction( + () => { + const aiChat = document.querySelector('.AI-Chat-slot')?.getBoundingClientRect(); + const workbench = document.querySelector('#workbench-editor')?.getBoundingClientRect(); + + return Boolean(aiChat && workbench && aiChat.width >= 640 && aiChat.x < workbench.x); + }, + undefined, + { timeout: 30_000 }, + ); +} + +export async function waitForExplorerViewVisible(page: Page): Promise { + const explorerEntry = page.locator('#opensumi-left-tabbar li#explorer'); + await explorerEntry.waitFor({ state: 'visible', timeout: EXPLORER_VIEW_READY_TIMEOUT_MS }); + + const isActive = await explorerEntry.evaluate((element) => element.classList.contains('active')); + if (!isActive) { + await explorerEntry.click(); + } + + await page.waitForFunction( + () => { + const isVisible = (element: Element) => { + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + + return rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden' && style.display !== 'none'; + }; + + const explorerEntry = document.querySelector('#opensumi-left-tabbar li#explorer'); + if (!explorerEntry || !isVisible(explorerEntry) || !explorerEntry.classList.contains('active')) { + return false; + } + + return Array.from(document.querySelectorAll('[data-viewlet-id="explorer"]')).some(isVisible); + }, + undefined, + { timeout: EXPLORER_VIEW_READY_TIMEOUT_MS }, + ); +} + +export async function waitForAcpChatReady(page: Page): Promise { + await page.waitForFunction( + () => { + const slot = document.querySelector('.AI-Chat-slot'); + if (!slot) { + return false; + } + + const slotRect = slot.getBoundingClientRect(); + const slotText = slot.textContent || ''; + if (slotRect.width <= 0 || slotRect.height <= 0 || slotText.includes('Initializing ACP service')) { + return false; + } + + const hasVisibleInput = Array.from( + slot.querySelectorAll('textarea, input, [role="textbox"], [contenteditable="true"]'), + ).some((element) => { + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + return rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden' && style.display !== 'none'; + }); + const hasAcpHistory = Boolean( + slot.querySelector('[data-testid="acp-chat-history-inline"], [data-testid="acp-chat-history-button"]'), + ); + + return hasVisibleInput || hasAcpHistory || slotText.includes('AI Assistant'); + }, + undefined, + { timeout: ACP_CHAT_READY_TIMEOUT_MS }, + ); +} + +export function aiNativeWorkbenchUrl( + workspaceDir: string, + profile: WebMcpProfile = 'default', + panelLayout: AiNativePanelLayout = 'agentic', + options: { forceAcpBackendReadyFailure?: boolean } = {}, +): string { + const params = new URLSearchParams({ workspaceDir, aiNative: 'true', aiPanelLayout: panelLayout }); + if (profile !== 'default') { + params.set('webMcpProfile', profile); + } + if (options.forceAcpBackendReadyFailure) { + params.set(ACP_BDD_BACKEND_READY_FAILURE_QUERY_PARAM, ACP_BDD_BACKEND_READY_FAILURE_QUERY_VALUE); + } + return `/?${params.toString()}`; +} + +export async function loadAcpBddFixtureWorkbench( + page: Page, + options: AcpBddFixtureOptions, +): Promise { + assertSupportedFixture(options.fixture); + const runtimeOptions = withRuntimeDefaults(options); + const releaseLock = await acquireRuntimeLock(); + + let app: OpenSumiApp | undefined; + let workspace: OpenSumiWorkspace | undefined; + + try { + if (runtimeOptions.viewport) { + await page.setViewportSize(runtimeOptions.viewport); + } + + const profile = runtimeOptions.profile || 'default'; + const panelLayout = runtimeOptions.panelLayout || 'agentic'; + workspace = new OpenSumiWorkspace(runtimeOptions.workspaceFiles || [ACP_BDD_DEFAULT_WORKSPACE]); + await workspace.initWorksapce(); + const workspaceDir = workspace.workspace.codeUri.fsPath; + await writeMockAcpAgentSettings(workspaceDir, runtimeOptions); + await writeAiNativePanelLayoutSettings(workspaceDir, panelLayout); + + app = new OpenSumiApp(page); + const url = aiNativeWorkbenchUrl(workspaceDir, profile, panelLayout, { + forceAcpBackendReadyFailure: runtimeOptions.forceAcpBackendReadyFailure, + }); + await page.goto(url); + await waitForWorkbenchReady(page); + + if (runtimeOptions.waitForModelContext !== false || runtimeOptions.showChatView) { + await page.waitForFunction(() => Boolean((navigator as any).modelContext?.executeTool), undefined, { + timeout: MODEL_CONTEXT_TIMEOUT_MS, + }); + } + if (runtimeOptions.showChatView) { + await page.evaluate(async () => { + await (navigator as any).modelContext.executeTool('acp_chat_show_chat_view', {}); + }); + await waitForAcpChatReady(page); + } + if (runtimeOptions.ensureAgenticLayout) { + await ensureAgenticLayout(page); + } + + return { + app, + workspace, + fixture: runtimeOptions.fixture, + profile, + workspaceDir, + url: page.url(), + async dispose() { + app?.dispose(); + workspace?.dispose(); + await releaseLock(); + }, + }; + } catch (error) { + app?.dispose(); + workspace?.dispose(); + await releaseLock(); + throw error; + } +} + +export async function runAcpBddFixturePasses( + page: Page, + passes: AcpBddFixturePass[], + runPass: (runtime: AcpBddFixtureRuntime, pass: AcpBddFixturePass) => Promise, +): Promise { + const results: T[] = []; + + for (const pass of passes) { + const runtime = await loadAcpBddFixtureWorkbench(page, pass); + try { + results.push(await runPass(runtime, pass)); + } finally { + await runtime.dispose(); + } + } + + return results; +} diff --git a/tools/playwright/src/tests/utils/bdd-evidence.ts b/tools/playwright/src/tests/utils/bdd-evidence.ts new file mode 100644 index 0000000000..2e4177d3fa --- /dev/null +++ b/tools/playwright/src/tests/utils/bdd-evidence.ts @@ -0,0 +1,229 @@ +import { promises as fs } from 'fs'; +import path from 'path'; + +import type { Page, TestInfo } from '@playwright/test'; + +type CriticalPointStatus = 'pass' | 'blocked' | 'fail'; +type ScenarioVerdict = 'PASS' | 'BLOCKED' | 'FAIL'; +type HardeningVerdict = 'CONVERT' | 'DEFER' | 'DO_NOT_CONVERT'; + +interface BddEvidenceOptions { + sourceScenario?: string; + profile?: string; + executionMode?: string; + hardeningVerdict?: HardeningVerdict; +} + +interface CriticalPoint { + id: string; + requirement: string; + status: CriticalPointStatus; + evidence: string[]; + notes?: string; +} + +interface Artifact { + file: string; + type: 'screenshot' | 'json'; + purpose: string; +} + +interface FinalizeOptions { + scenarioVerdict: ScenarioVerdict; + hardeningVerdict?: HardeningVerdict; + runtime?: Record; +} + +const ENABLED_ENV = 'OPENSUMI_BDD_EVIDENCE'; +const DIR_ENV = 'OPENSUMI_BDD_EVIDENCE_DIR'; +const REPO_ROOT = path.resolve(__dirname, '../../../../..'); +const DEFAULT_EVIDENCE_ROOT = path.join(REPO_ROOT, 'test/bdd/evidence'); + +function evidenceEnabled(): boolean { + return ['1', 'true', 'yes', 'on'].includes(String(process.env[ENABLED_ENV] || '').toLowerCase()); +} + +function today(): string { + return new Date().toISOString().slice(0, 10); +} + +function sanitizeFilename(value: string): string { + return ( + value + .trim() + .replace(/[^a-zA-Z0-9._-]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 120) || 'artifact' + ); +} + +function redactString(value: string): string { + return value + .replace(/\/mcp\/[A-Za-z0-9._~:/?#\[\]@!$&'()*+,;=%-]+/g, '/mcp/') + .replace(/(["']?(?:apiKey|api_key|token|authorization|password|secret)["']?\s*[:=]\s*["'])[^"']+/gi, '$1') + .replace(/\b(?:sk|xox[baprs]|gh[pousr])_[A-Za-z0-9_-]{12,}\b/g, ''); +} + +function redactValue(value: unknown): unknown { + if (typeof value === 'string') { + return redactString(value); + } + if (Array.isArray(value)) { + return value.map((item) => redactValue(item)); + } + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value as Record).map(([key, item]) => [key, redactValue(item)]), + ); + } + return value; +} + +function relativeToEvidenceDir(evidenceDir: string, filePath: string): string { + return path.relative(evidenceDir, filePath).replace(/\\/g, '/'); +} + +export class BddEvidence { + private artifacts: Artifact[] = []; + private criticalPoints: CriticalPoint[] = []; + private finalized = false; + + constructor( + private readonly enabled: boolean, + private readonly evidenceDir: string, + private readonly scenarioName: string, + private readonly options: BddEvidenceOptions, + private readonly testInfo: TestInfo, + ) {} + + get isEnabled(): boolean { + return this.enabled; + } + + async captureScreenshot(page: Page, name: string, purpose: string): Promise { + if (!this.enabled) { + return undefined; + } + const fileName = `${sanitizeFilename(name)}.png`; + const filePath = path.join(this.evidenceDir, fileName); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await page.screenshot({ path: filePath }); + const relativePath = relativeToEvidenceDir(this.evidenceDir, filePath); + this.artifacts.push({ file: relativePath, type: 'screenshot', purpose }); + return relativePath; + } + + async saveJson(name: string, data: unknown, purpose: string): Promise { + if (!this.enabled) { + return undefined; + } + const fileName = `${sanitizeFilename(name)}.json`; + const filePath = path.join(this.evidenceDir, fileName); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, `${JSON.stringify(redactValue(data), null, 2)}\n`, 'utf8'); + const relativePath = relativeToEvidenceDir(this.evidenceDir, filePath); + this.artifacts.push({ file: relativePath, type: 'json', purpose }); + return relativePath; + } + + recordCriticalPoint(point: CriticalPoint): void { + if (!this.enabled) { + return; + } + this.criticalPoints.push({ + ...point, + evidence: point.evidence.filter(Boolean), + }); + } + + async finalize(options: FinalizeOptions): Promise { + if (!this.enabled || this.finalized) { + return; + } + this.finalized = true; + await fs.mkdir(this.evidenceDir, { recursive: true }); + const payload = { + scenario: this.scenarioName, + sourceScenario: this.options.sourceScenario || '', + profile: this.options.profile || '', + executionMode: this.options.executionMode || 'deterministic-fixture', + testTitle: this.testInfo.title, + createdAt: new Date().toISOString(), + runtime: redactValue(options.runtime || {}), + criticalPoints: this.criticalPoints, + artifacts: this.artifacts, + scenarioVerdict: options.scenarioVerdict, + hardeningVerdict: options.hardeningVerdict || this.options.hardeningVerdict || 'DEFER', + }; + + await fs.writeFile(path.join(this.evidenceDir, 'evidence.json'), `${JSON.stringify(payload, null, 2)}\n`, 'utf8'); + await fs.writeFile(path.join(this.evidenceDir, 'report.md'), this.renderReport(payload), 'utf8'); + } + + private renderReport(payload: { + scenario: string; + sourceScenario: string; + profile: string; + executionMode: string; + testTitle: string; + runtime: unknown; + criticalPoints: CriticalPoint[]; + artifacts: Artifact[]; + scenarioVerdict: ScenarioVerdict; + hardeningVerdict: HardeningVerdict; + }): string { + const cpRows = payload.criticalPoints.length + ? payload.criticalPoints + .map( + (point) => + `| ${point.id} | ${point.status.toUpperCase()} | ${point.requirement} | ${ + point.evidence.join('
') || '-' + } | ${point.notes || ''} |`, + ) + .join('\n') + : '| - | - | No critical points recorded. | - | - |'; + const artifactRows = payload.artifacts.length + ? payload.artifacts.map((artifact) => `| ${artifact.file} | ${artifact.type} | ${artifact.purpose} |`).join('\n') + : '| - | - | No artifacts recorded. |'; + + return `# BDD Evidence: ${payload.scenario} + +**Source:** ${payload.sourceScenario || '-'} +**Profile:** ${payload.profile || '-'} +**Execution mode:** ${payload.executionMode} +**Test:** ${payload.testTitle} +**Scenario verdict:** ${payload.scenarioVerdict} +**Hardening verdict:** ${payload.hardeningVerdict} + +## Runtime + +\`\`\`json +${JSON.stringify(payload.runtime, null, 2)} +\`\`\` + +## Critical Points + +| CP | Result | Requirement | Evidence | Notes | +| --- | --- | --- | --- | --- | +${cpRows} + +## Evidence Files + +| File | Type | Purpose | +| --- | --- | --- | +${artifactRows} +`; + } +} + +export function createBddEvidence( + testInfo: TestInfo, + scenarioName: string, + options: BddEvidenceOptions = {}, +): BddEvidence { + const enabled = evidenceEnabled(); + const root = process.env[DIR_ENV] ? path.resolve(process.env[DIR_ENV]) : path.join(DEFAULT_EVIDENCE_ROOT, today()); + const evidenceDir = path.join(root, sanitizeFilename(scenarioName)); + + return new BddEvidence(enabled, evidenceDir, scenarioName, options, testInfo); +} 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