From 3ff54052f71bc379cc15c6eab375d6c480615abc Mon Sep 17 00:00:00 2001 From: skyfire Date: Wed, 14 Jan 2026 16:20:53 +0800 Subject: [PATCH 01/49] fix ambiguous val --- packages/sdk-java/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sdk-java/pom.xml b/packages/sdk-java/pom.xml index 6a5fae4f4..9ce3df0d5 100644 --- a/packages/sdk-java/pom.xml +++ b/packages/sdk-java/pom.xml @@ -5,7 +5,7 @@ com.alibaba qwencode-sdk jar - 0.0.1-alpha + 0.0.2-alpha qwencode-sdk The Qwen Code Java SDK is a minimum experimental SDK for programmatic access to Qwen Code functionality. It provides a Java interface to interact with the Qwen Code CLI, allowing developers to integrate Qwen Code capabilities into their Java applications. From 3c46278fad49becfd1fe616112b6d71ec67ed1a8 Mon Sep 17 00:00:00 2001 From: skyfire Date: Wed, 14 Jan 2026 16:48:31 +0800 Subject: [PATCH 02/49] add release.md --- packages/sdk-java/RELEASE.md | 150 +++++++++++++++++++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 packages/sdk-java/RELEASE.md diff --git a/packages/sdk-java/RELEASE.md b/packages/sdk-java/RELEASE.md new file mode 100644 index 000000000..520ea1e7d --- /dev/null +++ b/packages/sdk-java/RELEASE.md @@ -0,0 +1,150 @@ +# Release Notes + +### Changes in 0.0.2-alpha + +### Summary + +This release includes a fix for modifying some fields as referenced in issue #1459. + +#### Fix + +- Issue: modify some fields #1459 + +### Release Date + +January 14, 2026 + +### Maven Configuration + +```xml + + com.alibaba + qwencode-sdk + 0.0.2-alpha + +``` + +### Changes in 0.0.1-alpha + +### Summary + +This release includes updates to the Qwen Code Java SDK with improved session management, enhanced transport options, and better error handling capabilities. + +### Maven Configuration + +```xml + + com.alibaba + qwencode-sdk + 0.0.1-alpha + +``` + +#### Gradle Configuration + +```gradle +implementation 'com.alibaba:qwencode-sdk:0.0.1-alpha' +``` + +### Release Date + +January 5, 2026 + +#### New Features + +- Enhanced session management with dynamic model switching +- Improved permission mode controls with multiple options (default, plan, auto-edit, yolo) +- Support for streaming content handling with custom content consumers +- Thread pool configuration for managing concurrent operations +- Session resumption capabilities using resumeSessionId +- Dynamic permission mode switching during active sessions + +#### Improvements + +- Better timeout handling with configurable session and message timeouts +- Enhanced error handling with specific exception types +- Improved transport options configuration +- More flexible environment variable passing to CLI process +- Better support for partial message streaming + +#### Bug Fixes + +- Fixed session interruption handling +- Resolved issues with tool execution permissions +- Improved stability of process transport communication +- Fixed potential resource leaks in session cleanup + +### Known Issues + +1. **CLI Bundling**: From v0.1.1, the CLI is bundled with the SDK, eliminating the need for separate CLI installation. However, users upgrading from earlier versions should remove any standalone CLI installations to avoid conflicts. + +2. **Memory Management**: Long-running sessions with extensive streaming content may consume significant memory. Proper session cleanup using `session.close()` is essential. + +3. **Thread Pool Configuration**: The default thread pool configuration (30 core, 100 max threads) may need adjustment based on application load and concurrent session requirements. + +4. **Timeout Configuration**: Users experiencing timeout issues should adjust the `turnTimeout` and `messageTimeout` values in `TransportOptions` based on their specific use cases. + +5. **Permission Mode Confusion**: The different permission modes (default, plan, auto-edit, yolo) may cause confusion for new users. Clear documentation and examples are needed to guide users in selecting appropriate permission modes. + +6. **Environment Variable Limitations**: Environment variables passed to the CLI process may have platform-specific limitations on length and character sets. + +### Maven Build Configuration + +The project uses Maven for build management with the following key plugins and configurations: + +#### Compiler Plugin + +- Source and Target: Java 1.8 +- Encoding: UTF-8 + +#### Dependencies + +- Logging: ch.qos.logback:logback-classic +- Utilities: org.apache.commons:commons-lang3 +- JSON Processing: com.alibaba.fastjson2:fastjson2 +- Testing: JUnit 5 (org.junit.jupiter:junit-jupiter) + +#### Build Plugins + +- **Checkstyle Plugin**: Enforces code style consistency using checkstyle.xml configuration +- **JaCoCo Plugin**: Provides code coverage reports during testing +- **Central Publishing Plugin**: Enables publishing to Maven Central +- **Source Plugin**: Generates and attaches source JARs +- **Javadoc Plugin**: Generates and attaches Javadoc JARs +- **GPG Plugin**: Signs artifacts for secure publishing to Maven Central + +#### Distribution Management + +- Snapshot Repository: https://central.sonatype.com/repository/maven-snapshots/ +- Release Repository: https://central.sonatype.org/service/local/staging/deploy/maven2/ + +### Deployment Instructions + +To deploy a new version of the SDK: + +1. Update the version in `pom.xml` +2. Run `mvn clean deploy` to build and deploy to Maven Central +3. Ensure GPG signing keys are properly configured +4. Verify the deployment in the Sonatype staging repository + +### Future Enhancements + +Planned improvements for upcoming releases: + +1. **Enhanced Security**: Additional authentication mechanisms and secure credential handling +2. **Performance Optimization**: Improved memory usage and faster response times +3. **Extended API Coverage**: More comprehensive coverage of Qwen Code CLI features +4. **Better Documentation**: Expanded examples and API reference materials +5. **Improved Error Recovery**: More robust handling of connection failures and retries + +### Support and Contributions + +For support, bug reports, or contributions: + +- Issue Tracker: https://github.com/QwenLM/qwen-code/issues +- Documentation: Refer to README.md and Javadoc +- Contributions: Pull requests are welcome following the project's contribution guidelines + +### License + +This project is licensed under the Apache 2.0 License - see the [LICENSE](./LICENSE) file for details. From cea97a0107991d26d7aa0f29db412e95174d04ee Mon Sep 17 00:00:00 2001 From: ojha Date: Tue, 27 Jan 2026 21:13:45 +0530 Subject: [PATCH 03/49] feat: auto-convert Gemini extensions when gemini-extension.json exists Fixes installation failure for Gemini CLI extensions (e.g., conductor) that only provide gemini-extension.json but not qwen-extension.json. Instead of strict schema validation via isGeminiExtensionConfig(), now automatically attempts conversion whenever gemini-extension.json exists. Fixes: #1621 --- package-lock.json | 73 +------------------ .../core/src/extension/extensionManager.ts | 35 ++++++--- 2 files changed, 27 insertions(+), 81 deletions(-) diff --git a/package-lock.json b/package-lock.json index e3e7405e1..a3edbe11e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3878,19 +3878,13 @@ "version": "2.4.9", "resolved": "https://registry.npmjs.org/@types/prompts/-/prompts-2.4.9.tgz", "integrity": "sha512-qTxFi6Buiu8+50/+3DGIWLHM6QuWsEKugJnnP6iv2Mc4ncxE4A/OJkjuVOA+5X0X1S/nq5VJRa8Lu+nwcvbrKA==", + "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", "kleur": "^3.0.3" } }, - "node_modules/@types/prop-types": { - "version": "15.7.15", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", - "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/qrcode-terminal": { "version": "0.12.2", "resolved": "https://registry.npmjs.org/@types/qrcode-terminal/-/qrcode-terminal-0.12.2.tgz", @@ -13695,7 +13689,6 @@ "version": "19.1.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", - "dev": true, "license": "MIT", "dependencies": { "scheduler": "^0.26.0" @@ -17329,7 +17322,6 @@ "@iarna/toml": "^2.2.5", "@modelcontextprotocol/sdk": "^1.25.1", "@qwen-code/qwen-code-core": "file:../core", - "@types/prompts": "^2.4.9", "@types/update-notifier": "^6.0.8", "ansi-regex": "^6.2.2", "command-exists": "^1.2.9", @@ -17373,6 +17365,7 @@ "@types/diff": "^7.0.2", "@types/dotenv": "^6.1.1", "@types/node": "^20.11.24", + "@types/prompts": "^2.4.9", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@types/semver": "^7.7.0", @@ -21521,27 +21514,6 @@ "zod": "^3.25 || ^4" } }, - "packages/vscode-ide-companion/node_modules/@types/react": { - "version": "18.3.27", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", - "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/prop-types": "*", - "csstype": "^3.2.2" - } - }, - "packages/vscode-ide-companion/node_modules/@types/react-dom": { - "version": "18.3.7", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", - "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^18.0.0" - } - }, "packages/vscode-ide-companion/node_modules/@types/semver": { "version": "7.7.1", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", @@ -21584,13 +21556,6 @@ "node": ">= 0.6" } }, - "packages/vscode-ide-companion/node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, - "license": "MIT" - }, "packages/vscode-ide-companion/node_modules/express": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", @@ -21671,40 +21636,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "packages/vscode-ide-companion/node_modules/react": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", - "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "packages/vscode-ide-companion/node_modules/react-dom": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", - "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.23.2" - }, - "peerDependencies": { - "react": "^18.3.1" - } - }, - "packages/vscode-ide-companion/node_modules/scheduler": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", - "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - } - }, "packages/vscode-ide-companion/node_modules/send": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", diff --git a/packages/core/src/extension/extensionManager.ts b/packages/core/src/extension/extensionManager.ts index 921d34739..6211d41e4 100644 --- a/packages/core/src/extension/extensionManager.ts +++ b/packages/core/src/extension/extensionManager.ts @@ -38,10 +38,7 @@ import { } from './github.js'; import type { LoadExtensionContext } from './variableSchema.js'; import { Override, type AllExtensionsEnablementConfig } from './override.js'; -import { - isGeminiExtensionConfig, - convertGeminiExtensionPackage, -} from './gemini-converter.js'; +import { convertGeminiExtensionPackage } from './gemini-converter.js'; import { convertClaudePluginPackage } from './claude-converter.js'; import { glob } from 'glob'; import { createHash } from 'node:crypto'; @@ -244,18 +241,36 @@ async function convertGeminiOrClaudeExtension( pluginName?: string, ) { let newExtensionDir = extensionDir; - const configFilePath = path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME); - if (fs.existsSync(configFilePath)) { + const qwenConfigPath = path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME); + const geminiConfigPath = path.join(extensionDir, 'gemini-extension.json'); + + if (fs.existsSync(qwenConfigPath)) { + // Already a Qwen extension — no conversion needed newExtensionDir = extensionDir; - } else if (isGeminiExtensionConfig(extensionDir)) { - newExtensionDir = (await convertGeminiExtensionPackage(extensionDir)) - .convertedDir; + } else if (fs.existsSync(geminiConfigPath)) { + // Found gemini-extension.json — attempt conversion regardless of content validation + // This enables compatibility with ALL Gemini CLI extensions + try { + console.warn( + `⚠️ Found gemini-extension.json but not ${EXTENSIONS_CONFIG_FILENAME}. ` + + `Attempting automatic conversion for Qwen Code compatibility...`, + ); + newExtensionDir = (await convertGeminiExtensionPackage(extensionDir)) + .convertedDir; + console.warn(`✅ Successfully converted to Qwen Code format`); + } catch (error) { + // Provide helpful error instead of silent failure + throw new Error( + `Failed to convert Gemini extension: ${getErrorMessage(error)}\n` + + `Ensure gemini-extension.json exists and is valid JSON.`, + ); + } } else if (pluginName) { + // Claude plugin conversion (unchanged) newExtensionDir = ( await convertClaudePluginPackage(extensionDir, pluginName) ).convertedDir; } - // Claude plugin conversion not yet implemented return newExtensionDir; } From 6f7180251baabc0e7e9b2a1603616e2094673cc1 Mon Sep 17 00:00:00 2001 From: ojha Date: Fri, 30 Jan 2026 05:33:11 +0530 Subject: [PATCH 04/49] feat: add Gemini extension origin warning in consent prompt Shows warning when installing Gemini CLI extensions to set user expectations about potential compatibility differences. - Keeps isGeminiExtensionConfig validation intact (no skipped checks) - Zero UI in core package (warning only in CLI layer via consent.ts) - Uses t() for internationalization - Warning appears in consent prompt before installation Fixes: #1621 --- .../cli/src/commands/extensions/consent.ts | 9 ++++ .../cli/src/commands/extensions/install.ts | 4 +- .../core/src/extension/extensionManager.ts | 41 ++++++++++++------- 3 files changed, 38 insertions(+), 16 deletions(-) diff --git a/packages/cli/src/commands/extensions/consent.ts b/packages/cli/src/commands/extensions/consent.ts index cfff6e5b7..a248795de 100644 --- a/packages/cli/src/commands/extensions/consent.ts +++ b/packages/cli/src/commands/extensions/consent.ts @@ -148,8 +148,16 @@ export function extensionConsentString( commands: string[] = [], skills: SkillConfig[] = [], subagents: SubagentConfig[] = [], + isGeminiExtension: boolean = false, ): string { const output: string[] = []; + if (isGeminiExtension) { + output.push( + t( + '⚠️ You are installing a Gemini CLI extension. Some features may not work perfectly with Qwen Code.', + ), + ); + } const mcpServerEntries = Object.entries(extensionConfig.mcpServers || {}); output.push( t('Installing extension "{{name}}".', { name: extensionConfig.name }), @@ -234,6 +242,7 @@ export const requestConsentOrFail = async ( commands, skills, subagents, + options.isGeminiExtension ?? false, ); if (previousExtensionConfig) { const previousExtensionConsent = extensionConsentString( diff --git a/packages/cli/src/commands/extensions/install.ts b/packages/cli/src/commands/extensions/install.ts index f7fda09df..62ae710f9 100644 --- a/packages/cli/src/commands/extensions/install.ts +++ b/packages/cli/src/commands/extensions/install.ts @@ -10,6 +10,7 @@ import { ExtensionManager, parseInstallSource, } from '@qwen-code/qwen-code-core'; +import type { ExtensionRequestOptions } from '@qwen-code/qwen-code-core'; import { getErrorMessage } from '../../utils/errors.js'; import { isWorkspaceTrusted } from '../../config/trustedFolders.js'; import { loadSettings } from '../../config/settings.js'; @@ -47,7 +48,8 @@ export async function handleInstall(args: InstallArgs) { const requestConsent = args.consent ? () => Promise.resolve() - : requestConsentOrFail.bind(null, requestConsentNonInteractive); + : (options?: ExtensionRequestOptions) => + requestConsentOrFail(requestConsentNonInteractive, options); const workspaceDir = process.cwd(); const extensionManager = new ExtensionManager({ workspaceDir, diff --git a/packages/core/src/extension/extensionManager.ts b/packages/core/src/extension/extensionManager.ts index 731820be4..107a8813f 100644 --- a/packages/core/src/extension/extensionManager.ts +++ b/packages/core/src/extension/extensionManager.ts @@ -38,7 +38,10 @@ import { } from './github.js'; import type { LoadExtensionContext } from './variableSchema.js'; import { Override, type AllExtensionsEnablementConfig } from './override.js'; -import { convertGeminiExtensionPackage } from './gemini-converter.js'; +import { + isGeminiExtensionConfig, + convertGeminiExtensionPackage, +} from './gemini-converter.js'; import { convertClaudePluginPackage } from './claude-converter.js'; import { glob } from 'glob'; import { createHash } from 'node:crypto'; @@ -137,6 +140,7 @@ export type ExtensionRequestOptions = { previousCommands?: string[]; previousSkills?: SkillConfig[]; previousSubagents?: SubagentConfig[]; + isGeminiExtension?: boolean; }; export interface ExtensionManagerOptions { @@ -249,23 +253,15 @@ async function convertGeminiOrClaudeExtension( // Already a Qwen extension — no conversion needed newExtensionDir = extensionDir; } else if (fs.existsSync(geminiConfigPath)) { - // Found gemini-extension.json — attempt conversion regardless of content validation - // This enables compatibility with ALL Gemini CLI extensions - try { - console.warn( - `⚠️ Found gemini-extension.json but not ${EXTENSIONS_CONFIG_FILENAME}. ` + - `Attempting automatic conversion for Qwen Code compatibility...`, - ); - newExtensionDir = (await convertGeminiExtensionPackage(extensionDir)) - .convertedDir; - console.warn(`✅ Successfully converted to Qwen Code format`); - } catch (error) { - // Provide helpful error instead of silent failure + // VALIDATE FIRST (maintainer requirement) + if (!isGeminiExtensionConfig(extensionDir)) { throw new Error( - `Failed to convert Gemini extension: ${getErrorMessage(error)}\n` + - `Ensure gemini-extension.json exists and is valid JSON.`, + `Invalid gemini-extension.json: missing required fields (name/version)`, ); } + // THEN convert + newExtensionDir = (await convertGeminiExtensionPackage(extensionDir)) + .convertedDir; } else if (pluginName) { // Claude plugin conversion (unchanged) newExtensionDir = ( @@ -816,10 +812,23 @@ export class ExtensionManager { } try { + // Save original path BEFORE conversion to detect Gemini origin + const originalSourcePath = localSourcePath; + localSourcePath = await convertGeminiOrClaudeExtension( localSourcePath, installMetadata.pluginName, ); + + // Detect if this was a Gemini extension (had gemini-extension.json but not qwen-extension.json) + const isGeminiExtension = + fs.existsSync( + path.join(originalSourcePath, 'gemini-extension.json'), + ) && + !fs.existsSync( + path.join(originalSourcePath, EXTENSIONS_CONFIG_FILENAME), + ); + newExtensionConfig = this.loadExtensionConfig({ extensionDir: localSourcePath, workspaceDir: currentDir, @@ -881,6 +890,7 @@ export class ExtensionManager { previousCommands, previousSkills, previousSubagents, + isGeminiExtension, }); } else { await this.requestConsent({ @@ -892,6 +902,7 @@ export class ExtensionManager { previousCommands, previousSkills, previousSubagents, + isGeminiExtension, }); } From 7e5c1ae43afa6b7a3bfb607defa2d11580417ebc Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Sat, 31 Jan 2026 12:17:46 +0800 Subject: [PATCH 05/49] refactor: remove read_many_files tool, add readManyFiles utility for user @-commands Co-authored-by: Qwen-Coder --- .../acp-integration/session/Session.test.ts | 31 +- .../src/acp-integration/session/Session.ts | 198 +---- packages/cli/src/config/config.ts | 12 - packages/cli/src/gemini.test.tsx | 1 - packages/cli/src/nonInteractiveCli.ts | 1 - .../src/ui/hooks/atCommandProcessor.test.ts | 375 +++++---- .../cli/src/ui/hooks/atCommandProcessor.ts | 257 +++--- packages/cli/src/ui/hooks/useGeminiStream.ts | 23 +- .../cli/src/ui/utils/resumeHistoryUtils.ts | 122 ++- packages/core/src/config/config.test.ts | 4 - packages/core/src/config/config.ts | 9 - .../core/__snapshots__/prompts.test.ts.snap | 98 +-- packages/core/src/core/prompts.ts | 14 +- packages/core/src/index.ts | 3 +- .../src/services/chatRecordingService.test.ts | 28 + .../core/src/services/chatRecordingService.ts | 41 +- .../core/src/tools/read-many-files.test.ts | 759 ------------------ packages/core/src/tools/read-many-files.ts | 578 ------------- packages/core/src/tools/tool-error.ts | 3 - packages/core/src/tools/tool-names.ts | 2 - .../core/src/utils/environmentContext.test.ts | 91 --- packages/core/src/utils/environmentContext.ts | 41 +- .../core/src/utils/getFolderStructure.test.ts | 20 +- packages/core/src/utils/getFolderStructure.ts | 20 +- packages/core/src/utils/readManyFiles.test.ts | 298 +++++++ packages/core/src/utils/readManyFiles.ts | 210 +++++ 26 files changed, 1118 insertions(+), 2121 deletions(-) delete mode 100644 packages/core/src/tools/read-many-files.test.ts delete mode 100644 packages/core/src/tools/read-many-files.ts create mode 100644 packages/core/src/utils/readManyFiles.test.ts create mode 100644 packages/core/src/utils/readManyFiles.ts diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index 5f37e1103..ac9b3ff2b 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -11,6 +11,7 @@ import * as path from 'node:path'; import { Session } from './Session.js'; import type { Config, GeminiChat } from '@qwen-code/qwen-code-core'; import { ApprovalMode } from '@qwen-code/qwen-code-core'; +import * as core from '@qwen-code/qwen-code-core'; import type * as acp from '../acp.js'; import type { LoadedSettings } from '../../config/settings.js'; import * as nonInteractiveCliCommands from '../../nonInteractiveCliCommands.js'; @@ -203,24 +204,14 @@ describe('Session', () => { try { await fs.writeFile(filePath, '# Test\n', 'utf8'); - const readManyFilesTool = { - buildAndExecute: vi.fn().mockResolvedValue({ - llmContent: 'file content', - returnDisplay: 'ok', - }), - }; - const toolRegistry = { - getTool: vi.fn((name: string) => - name === 'read_many_files' ? readManyFilesTool : undefined, - ), - }; - const fileService = { - shouldGitIgnoreFile: vi.fn().mockReturnValue(false), - }; + const readManyFilesSpy = vi + .spyOn(core, 'readManyFiles') + .mockResolvedValue({ + contentParts: 'file content', + files: [], + }); mockConfig.getTargetDir = vi.fn().mockReturnValue(tempDir); - mockConfig.getToolRegistry = vi.fn().mockReturnValue(toolRegistry); - mockConfig.getFileService = vi.fn().mockReturnValue(fileService); mockChat.sendMessageStream = vi .fn() .mockResolvedValue((async function* () {})()); @@ -239,10 +230,10 @@ describe('Session', () => { await session.prompt(promptRequest); - expect(readManyFilesTool.buildAndExecute).toHaveBeenCalledWith( - { paths: [fileName] }, - expect.any(AbortSignal), - ); + expect(readManyFilesSpy).toHaveBeenCalledWith(mockConfig, { + paths: [fileName], + signal: expect.any(AbortSignal), + }); } finally { await fs.rm(tempDir, { recursive: true, force: true }); } diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 48d91fd0e..fa0ab7fb8 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -27,20 +27,16 @@ import { logToolCall, logUserPrompt, getErrorStatus, - isWithinRoot, - isNodeError, TaskTool, UserPromptEvent, TodoWriteTool, ExitPlanModeTool, + readManyFiles, } from '@qwen-code/qwen-code-core'; import * as acp from '../acp.js'; import type { LoadedSettings } from '../../config/settings.js'; -import * as fs from 'node:fs/promises'; -import * as path from 'node:path'; import { z } from 'zod'; -import { getErrorMessage } from '../../utils/errors.js'; import { normalizePartList } from '../../utils/nonInteractiveHelpers.js'; import { handleSlashCommand, @@ -831,120 +827,11 @@ export class Session implements SessionContext { return parts; } - const atPathToResolvedSpecMap = new Map(); - - // Get centralized file discovery service - const fileDiscovery = this.config.getFileService(); - const respectGitIgnore = this.config.getFileFilteringRespectGitIgnore(); - - const pathSpecsToRead: string[] = []; - const contentLabelsForDisplay: string[] = []; - const ignoredPaths: string[] = []; - - const toolRegistry = this.config.getToolRegistry(); - const readManyFilesTool = toolRegistry.getTool('read_many_files'); - const globTool = toolRegistry.getTool('glob'); - - if (!readManyFilesTool) { - throw new Error('Error: read_many_files tool not found.'); - } - - for (const atPathPart of atPathCommandParts) { - const pathName = atPathPart.fileData!.fileUri; - // Check if path should be ignored by git - if (fileDiscovery.shouldGitIgnoreFile(pathName)) { - ignoredPaths.push(pathName); - const reason = respectGitIgnore - ? 'git-ignored and will be skipped' - : 'ignored by custom patterns'; - console.warn(`Path ${pathName} is ${reason}.`); - continue; - } - let currentPathSpec = pathName; - let resolvedSuccessfully = false; - try { - const absolutePath = path.resolve(this.config.getTargetDir(), pathName); - if (isWithinRoot(absolutePath, this.config.getTargetDir())) { - const stats = await fs.stat(absolutePath); - if (stats.isDirectory()) { - currentPathSpec = pathName.endsWith('/') - ? `${pathName}**` - : `${pathName}/**`; - this.debug( - `Path ${pathName} resolved to directory, using glob: ${currentPathSpec}`, - ); - } else { - this.debug(`Path ${pathName} resolved to file: ${currentPathSpec}`); - } - resolvedSuccessfully = true; - } else { - this.debug( - `Path ${pathName} is outside the project directory. Skipping.`, - ); - } - } catch (error) { - if (isNodeError(error) && error.code === 'ENOENT') { - if (this.config.getEnableRecursiveFileSearch() && globTool) { - this.debug( - `Path ${pathName} not found directly, attempting glob search.`, - ); - try { - const globResult = await globTool.buildAndExecute( - { - pattern: `**/*${pathName}*`, - path: this.config.getTargetDir(), - }, - abortSignal, - ); - if ( - globResult.llmContent && - typeof globResult.llmContent === 'string' && - !globResult.llmContent.startsWith('No files found') && - !globResult.llmContent.startsWith('Error:') - ) { - const lines = globResult.llmContent.split('\n'); - if (lines.length > 1 && lines[1]) { - const firstMatchAbsolute = lines[1].trim(); - currentPathSpec = path.relative( - this.config.getTargetDir(), - firstMatchAbsolute, - ); - this.debug( - `Glob search for ${pathName} found ${firstMatchAbsolute}, using relative path: ${currentPathSpec}`, - ); - resolvedSuccessfully = true; - } else { - this.debug( - `Glob search for '**/*${pathName}*' did not return a usable path. Path ${pathName} will be skipped.`, - ); - } - } else { - this.debug( - `Glob search for '**/*${pathName}*' found no files or an error. Path ${pathName} will be skipped.`, - ); - } - } catch (globError) { - console.error( - `Error during glob search for ${pathName}: ${getErrorMessage(globError)}`, - ); - } - } else { - this.debug( - `Glob tool not found. Path ${pathName} will be skipped.`, - ); - } - } else { - console.error( - `Error stating path ${pathName}. Path ${pathName} will be skipped.`, - ); - } - } - if (resolvedSuccessfully) { - pathSpecsToRead.push(currentPathSpec); - atPathToResolvedSpecMap.set(pathName, currentPathSpec); - contentLabelsForDisplay.push(pathName); - } - } + // Extract paths from @ commands - pass directly to readManyFiles without filtering + // since this is user-triggered behavior, not LLM-triggered + const pathSpecsToRead: string[] = atPathCommandParts.map( + (part) => part.fileData!.fileUri, + ); // Construct the initial part of the query for the LLM let initialQueryText = ''; @@ -952,70 +839,49 @@ export class Session implements SessionContext { const chunk = parts[i]; if ('text' in chunk) { initialQueryText += chunk.text; - } else { - // type === 'atPath' - const resolvedSpec = - chunk.fileData && atPathToResolvedSpecMap.get(chunk.fileData.fileUri); + } else if ('fileData' in chunk) { + const pathName = chunk.fileData!.fileUri; if ( i > 0 && initialQueryText.length > 0 && - !initialQueryText.endsWith(' ') && - resolvedSpec + !initialQueryText.endsWith(' ') ) { - // Add space if previous part was text and didn't end with space, or if previous was @path - const prevPart = parts[i - 1]; - if ( - 'text' in prevPart || - ('fileData' in prevPart && - atPathToResolvedSpecMap.has(prevPart.fileData!.fileUri)) - ) { - initialQueryText += ' '; - } - } - // Append the resolved path spec for display purposes - if (resolvedSpec) { - initialQueryText += `@${resolvedSpec}`; + initialQueryText += ' '; } + initialQueryText += `@${pathName}`; } } - // Handle ignored paths message - let ignoredPathsMessage = ''; - if (ignoredPaths.length > 0) { - const pathList = ignoredPaths.map((p) => `- ${p}`).join('\n'); - ignoredPathsMessage = `Note: The following paths were skipped because they are ignored:\n${pathList}\n\n`; - } - const processedQueryParts: Part[] = []; - // Read files using read_many_files tool + // Read files using readManyFiles utility if (pathSpecsToRead.length > 0) { - const readResult = await readManyFilesTool.buildAndExecute( - { - paths: pathSpecsToRead, - }, - abortSignal, - ); + const readResult = await readManyFiles(this.config, { + paths: pathSpecsToRead, + signal: abortSignal, + }); - const contentForLlm = - typeof readResult.llmContent === 'string' - ? readResult.llmContent - : JSON.stringify(readResult.llmContent); + const contentParts = Array.isArray(readResult.contentParts) + ? readResult.contentParts + : [readResult.contentParts]; - // Combine content label, ignored paths message, file content, and user query - const combinedText = `${ignoredPathsMessage}${contentForLlm}`.trim(); - processedQueryParts.push({ text: combinedText }); + // Add initial query text first processedQueryParts.push({ text: initialQueryText }); + + // Then add content parts (preserving binary files as inlineData) + for (const part of contentParts) { + if (typeof part === 'string') { + processedQueryParts.push({ text: part }); + } else { + processedQueryParts.push(part); + } + } } else if (embeddedContext.length > 0) { // No @path files to read, but we have embedded context - processedQueryParts.push({ - text: `${ignoredPathsMessage}${initialQueryText}`.trim(), - }); + processedQueryParts.push({ text: initialQueryText.trim() }); } else { - // No @path files found or resolved - processedQueryParts.push({ - text: `${ignoredPathsMessage}${initialQueryText}`.trim(), - }); + // No @path files found + processedQueryParts.push({ text: initialQueryText.trim() }); } // Process embedded context from resource blocks diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index d4752d4be..88d1d65a6 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -102,7 +102,6 @@ export interface CliArgs { debug: boolean | undefined; prompt: string | undefined; promptInteractive: string | undefined; - allFiles: boolean | undefined; yolo: boolean | undefined; approvalMode: string | undefined; telemetry: boolean | undefined; @@ -290,12 +289,6 @@ export async function parseArguments(settings: Settings): Promise { type: 'string', description: 'Sandbox image URI.', }) - .option('all-files', { - alias: ['a'], - type: 'boolean', - description: 'Include ALL files in context?', - default: false, - }) .option('yolo', { alias: 'y', type: 'boolean', @@ -512,10 +505,6 @@ export async function parseArguments(settings: Settings): Promise { 'checkpointing', 'Use the "general.checkpointing.enabled" setting in settings.json instead. This flag will be removed in a future version.', ) - .deprecateOption( - 'all-files', - 'Use @ includes in the application instead. This flag will be removed in a future version.', - ) .deprecateOption( 'prompt', 'Use the positional prompt instead. This flag will be removed in a future version.', @@ -950,7 +939,6 @@ export async function loadCliConfig( importFormat: settings.context?.importFormat || 'tree', debugMode, question, - fullContext: argv.allFiles || false, coreTools: argv.coreTools || settings.tools?.core || undefined, allowedTools: argv.allowedTools || settings.tools?.allowed || undefined, excludeTools, diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 25db908c4..1a480c52b 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -449,7 +449,6 @@ describe('gemini.tsx main function kitty protocol', () => { prompt: undefined, promptInteractive: undefined, query: undefined, - allFiles: undefined, yolo: undefined, approvalMode: undefined, telemetry: undefined, diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index 634ad9399..85c050bce 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -224,7 +224,6 @@ export async function runNonInteractive( const { processedQuery, shouldProceed } = await handleAtCommand({ query: input, config, - addItem: (_item, _timestamp) => 0, onDebugMessage: () => {}, messageId: Date.now(), signal: abortController.signal, diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.test.ts b/packages/cli/src/ui/hooks/atCommandProcessor.test.ts index d86340283..3d734707b 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.test.ts @@ -10,10 +10,7 @@ import { handleAtCommand } from './atCommandProcessor.js'; import type { Config } from '@qwen-code/qwen-code-core'; import { FileDiscoveryService, - GlobTool, - ReadManyFilesTool, StandardFileSystemService, - ToolRegistry, COMMON_IGNORE_PATTERNS, // DEFAULT_FILE_EXCLUDES, } from '@qwen-code/qwen-code-core'; @@ -47,11 +44,9 @@ describe('handleAtCommand', () => { abortController = new AbortController(); - const getToolRegistry = vi.fn(); - mockConfig = { - getToolRegistry, getTargetDir: () => testRootDir, + getProjectRoot: () => testRootDir, isSandboxed: () => false, getFileService: () => new FileDiscoveryService(testRootDir), getFileFilteringRespectGitIgnore: () => true, @@ -83,11 +78,6 @@ describe('handleAtCommand', () => { getTruncateToolOutputThreshold: () => 2500, getTruncateToolOutputLines: () => 500, } as unknown as Config; - - const registry = new ToolRegistry(mockConfig); - registry.registerTool(new ReadManyFilesTool(mockConfig)); - registry.registerTool(new GlobTool(mockConfig)); - getToolRegistry.mockReturnValue(registry); }); afterEach(async () => { @@ -101,13 +91,12 @@ describe('handleAtCommand', () => { const result = await handleAtCommand({ query, config: mockConfig, - addItem: mockAddItem, onDebugMessage: mockOnDebugMessage, messageId: 123, signal: abortController.signal, }); - expect(result).toEqual({ + expect(result).toMatchObject({ processedQuery: [{ text: query }], shouldProceed: true, }); @@ -119,13 +108,12 @@ describe('handleAtCommand', () => { const result = await handleAtCommand({ query: queryWithSpaces, config: mockConfig, - addItem: mockAddItem, onDebugMessage: mockOnDebugMessage, messageId: 124, signal: abortController.signal, }); - expect(result).toEqual({ + expect(result).toMatchObject({ processedQuery: [{ text: queryWithSpaces }], shouldProceed: true, }); @@ -145,62 +133,59 @@ describe('handleAtCommand', () => { const result = await handleAtCommand({ query, config: mockConfig, - addItem: mockAddItem, onDebugMessage: mockOnDebugMessage, messageId: 125, signal: abortController.signal, }); - expect(result).toEqual({ - processedQuery: [ - { text: `@${filePath}` }, - { text: '\n--- Content from referenced files ---' }, - { text: `\nContent from @${filePath}:\n` }, - { text: fileContent }, - { text: '\n--- End of content ---' }, - ], - shouldProceed: true, - }); - expect(mockAddItem).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'tool_group', - tools: [expect.objectContaining({ status: ToolCallStatus.Success })], - }), - 125, - ); + expect(result.processedQuery).toEqual([ + { text: `@${filePath}` }, + { text: '\n--- Content from referenced files ---' }, + { text: `\nContent from ${filePath}:\n` }, + { text: fileContent }, + { text: '\n--- End of content ---' }, + ]); + expect(result.shouldProceed).toBe(true); + // toolDisplays should be returned for caller to add to UI history + expect(result.toolDisplays).toBeDefined(); + expect(result.toolDisplays).toHaveLength(1); + expect(result.toolDisplays![0].status).toBe(ToolCallStatus.Success); }); - it('should process a valid directory path and convert to glob', async () => { - const fileContent = 'This is the file content.'; + it('should process a valid directory path', async () => { const filePath = await createTestFile( path.join(testRootDir, 'path', 'to', 'file.txt'), - fileContent, + 'This is the file content.', ); const dirPath = path.dirname(filePath); const query = `@${dirPath}`; - const resolvedGlob = `${dirPath}/**`; const result = await handleAtCommand({ query, config: mockConfig, - addItem: mockAddItem, onDebugMessage: mockOnDebugMessage, messageId: 126, signal: abortController.signal, }); - expect(result).toEqual({ - processedQuery: [ - { text: `@${resolvedGlob}` }, - { text: '\n--- Content from referenced files ---' }, - { text: `\nContent from @${filePath}:\n` }, - { text: fileContent }, - { text: '\n--- End of content ---' }, - ], - shouldProceed: true, - }); + const processedText = Array.isArray(result.processedQuery) + ? result.processedQuery + .map((part) => + typeof part === 'string' + ? part + : 'text' in part + ? part.text + : JSON.stringify(part), + ) + .join('') + : ''; + + expect(processedText).toContain(`@${dirPath}`); + expect(processedText).toContain(`Content from ${dirPath}:`); + expect(processedText).toContain('Showing up to'); + expect(result.shouldProceed).toBe(true); expect(mockOnDebugMessage).toHaveBeenCalledWith( - `Path ${dirPath} resolved to directory, using glob: ${resolvedGlob}`, + `Path ${dirPath} resolved to directory.`, ); }); @@ -217,17 +202,16 @@ describe('handleAtCommand', () => { const result = await handleAtCommand({ query, config: mockConfig, - addItem: mockAddItem, onDebugMessage: mockOnDebugMessage, messageId: 128, signal: abortController.signal, }); - expect(result).toEqual({ + expect(result).toMatchObject({ processedQuery: [ { text: `${textBefore}@${filePath}${textAfter}` }, { text: '\n--- Content from referenced files ---' }, - { text: `\nContent from @${filePath}:\n` }, + { text: `\nContent from ${filePath}:\n` }, { text: fileContent }, { text: '\n--- End of content ---' }, ], @@ -247,29 +231,23 @@ describe('handleAtCommand', () => { const result = await handleAtCommand({ query, config: mockConfig, - addItem: mockAddItem, onDebugMessage: mockOnDebugMessage, messageId: 125, signal: abortController.signal, }); - expect(result).toEqual({ - processedQuery: [ - { text: `@${filePath}` }, - { text: '\n--- Content from referenced files ---' }, - { text: `\nContent from @${filePath}:\n` }, - { text: fileContent }, - { text: '\n--- End of content ---' }, - ], - shouldProceed: true, - }); - expect(mockAddItem).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'tool_group', - tools: [expect.objectContaining({ status: ToolCallStatus.Success })], - }), - 125, - ); + expect(result.processedQuery).toEqual([ + { text: `@${filePath}` }, + { text: '\n--- Content from referenced files ---' }, + { text: `\nContent from ${filePath}:\n` }, + { text: fileContent }, + { text: '\n--- End of content ---' }, + ]); + expect(result.shouldProceed).toBe(true); + // toolDisplays should be returned for caller to add to UI history + expect(result.toolDisplays).toBeDefined(); + expect(result.toolDisplays).toHaveLength(1); + expect(result.toolDisplays![0].status).toBe(ToolCallStatus.Success); }); it('should handle multiple @file references', async () => { @@ -288,19 +266,18 @@ describe('handleAtCommand', () => { const result = await handleAtCommand({ query, config: mockConfig, - addItem: mockAddItem, onDebugMessage: mockOnDebugMessage, messageId: 130, signal: abortController.signal, }); - expect(result).toEqual({ + expect(result).toMatchObject({ processedQuery: [ { text: query }, { text: '\n--- Content from referenced files ---' }, - { text: `\nContent from @${file1Path}:\n` }, + { text: `\nContent from ${file1Path}:\n` }, { text: content1 }, - { text: `\nContent from @${file2Path}:\n` }, + { text: `\nContent from ${file2Path}:\n` }, { text: content2 }, { text: '\n--- End of content ---' }, ], @@ -327,19 +304,18 @@ describe('handleAtCommand', () => { const result = await handleAtCommand({ query, config: mockConfig, - addItem: mockAddItem, onDebugMessage: mockOnDebugMessage, messageId: 131, signal: abortController.signal, }); - expect(result).toEqual({ + expect(result).toMatchObject({ processedQuery: [ { text: query }, { text: '\n--- Content from referenced files ---' }, - { text: `\nContent from @${file1Path}:\n` }, + { text: `\nContent from ${file1Path}:\n` }, { text: content1 }, - { text: `\nContent from @${file2Path}:\n` }, + { text: `\nContent from ${file2Path}:\n` }, { text: content2 }, { text: '\n--- End of content ---' }, ], @@ -364,31 +340,27 @@ describe('handleAtCommand', () => { const result = await handleAtCommand({ query, config: mockConfig, - addItem: mockAddItem, onDebugMessage: mockOnDebugMessage, messageId: 132, signal: abortController.signal, }); - expect(result).toEqual({ + expect(result).toMatchObject({ processedQuery: [ { text: `Look at @${file1Path} then @${invalidFile} and also just @ symbol, then @${file2Path}`, }, { text: '\n--- Content from referenced files ---' }, - { text: `\nContent from @${file2Path}:\n` }, - { text: content2 }, - { text: `\nContent from @${file1Path}:\n` }, + { text: `\nContent from ${file1Path}:\n` }, { text: content1 }, + { text: `\nContent from ${file2Path}:\n` }, + { text: content2 }, { text: '\n--- End of content ---' }, ], shouldProceed: true, }); expect(mockOnDebugMessage).toHaveBeenCalledWith( - `Path ${invalidFile} not found directly, attempting glob search.`, - ); - expect(mockOnDebugMessage).toHaveBeenCalledWith( - `Glob search for '**/*${invalidFile}*' found no files or an error. Path ${invalidFile} will be skipped.`, + `Path ${invalidFile} not found. Path ${invalidFile} will be skipped.`, ); expect(mockOnDebugMessage).toHaveBeenCalledWith( 'Lone @ detected, will be treated as text in the modified query.', @@ -401,13 +373,12 @@ describe('handleAtCommand', () => { const result = await handleAtCommand({ query, config: mockConfig, - addItem: mockAddItem, onDebugMessage: mockOnDebugMessage, messageId: 133, signal: abortController.signal, }); - expect(result).toEqual({ + expect(result).toMatchObject({ processedQuery: [{ text: 'Check @nonexistent.txt and @ also' }], shouldProceed: true, }); @@ -435,13 +406,12 @@ describe('handleAtCommand', () => { const result = await handleAtCommand({ query, config: mockConfig, - addItem: mockAddItem, onDebugMessage: mockOnDebugMessage, messageId: 200, signal: abortController.signal, }); - expect(result).toEqual({ + expect(result).toMatchObject({ processedQuery: [{ text: query }], shouldProceed: true, }); @@ -468,17 +438,16 @@ describe('handleAtCommand', () => { const result = await handleAtCommand({ query, config: mockConfig, - addItem: mockAddItem, onDebugMessage: mockOnDebugMessage, messageId: 201, signal: abortController.signal, }); - expect(result).toEqual({ + expect(result).toMatchObject({ processedQuery: [ { text: `@${validFile}` }, { text: '\n--- Content from referenced files ---' }, - { text: `\nContent from @${validFile}:\n` }, + { text: `\nContent from ${validFile}:\n` }, { text: 'console.log("Hello world");' }, { text: '\n--- End of content ---' }, ], @@ -501,17 +470,16 @@ describe('handleAtCommand', () => { const result = await handleAtCommand({ query, config: mockConfig, - addItem: mockAddItem, onDebugMessage: mockOnDebugMessage, messageId: 202, signal: abortController.signal, }); - expect(result).toEqual({ + expect(result).toMatchObject({ processedQuery: [ { text: `@${validFile} @${gitIgnoredFile}` }, { text: '\n--- Content from referenced files ---' }, - { text: `\nContent from @${validFile}:\n` }, + { text: `\nContent from ${validFile}:\n` }, { text: '# Project README' }, { text: '\n--- End of content ---' }, ], @@ -535,13 +503,12 @@ describe('handleAtCommand', () => { const result = await handleAtCommand({ query, config: mockConfig, - addItem: mockAddItem, onDebugMessage: mockOnDebugMessage, messageId: 203, signal: abortController.signal, }); - expect(result).toEqual({ + expect(result).toMatchObject({ processedQuery: [{ text: query }], shouldProceed: true, }); @@ -554,32 +521,6 @@ describe('handleAtCommand', () => { }); }); - describe('when recursive file search is disabled', () => { - beforeEach(() => { - vi.mocked(mockConfig.getEnableRecursiveFileSearch).mockReturnValue(false); - }); - - it('should not use glob search for a nonexistent file', async () => { - const invalidFile = 'nonexistent.txt'; - const query = `@${invalidFile}`; - - const result = await handleAtCommand({ - query, - config: mockConfig, - addItem: mockAddItem, - onDebugMessage: mockOnDebugMessage, - messageId: 300, - signal: abortController.signal, - }); - - expect(mockOnDebugMessage).toHaveBeenCalledWith( - `Glob tool not found. Path ${invalidFile} will be skipped.`, - ); - expect(result.processedQuery).toEqual([{ text: query }]); - expect(result.shouldProceed).toBe(true); - }); - }); - describe('qwen-ignore filtering', () => { it('should skip qwen-ignored files in @ commands', async () => { await createTestFile( @@ -595,13 +536,12 @@ describe('handleAtCommand', () => { const result = await handleAtCommand({ query, config: mockConfig, - addItem: mockAddItem, onDebugMessage: mockOnDebugMessage, messageId: 204, signal: abortController.signal, }); - expect(result).toEqual({ + expect(result).toMatchObject({ processedQuery: [{ text: query }], shouldProceed: true, }); @@ -627,17 +567,16 @@ describe('handleAtCommand', () => { const result = await handleAtCommand({ query, config: mockConfig, - addItem: mockAddItem, onDebugMessage: mockOnDebugMessage, messageId: 205, signal: abortController.signal, }); - expect(result).toEqual({ + expect(result).toMatchObject({ processedQuery: [ { text: `@${validFile}` }, { text: '\n--- Content from referenced files ---' }, - { text: `\nContent from @${validFile}:\n` }, + { text: `\nContent from ${validFile}:\n` }, { text: 'console.log("Hello world");' }, { text: '\n--- End of content ---' }, ], @@ -663,17 +602,16 @@ describe('handleAtCommand', () => { const result = await handleAtCommand({ query, config: mockConfig, - addItem: mockAddItem, onDebugMessage: mockOnDebugMessage, messageId: 206, signal: abortController.signal, }); - expect(result).toEqual({ + expect(result).toMatchObject({ processedQuery: [ { text: `@${validFile} @${qwenIgnoredFile}` }, { text: '\n--- Content from referenced files ---' }, - { text: `\nContent from @${validFile}:\n` }, + { text: `\nContent from ${validFile}:\n` }, { text: '// Main application entry' }, { text: '\n--- End of content ---' }, ], @@ -791,17 +729,16 @@ describe('handleAtCommand', () => { const result = await handleAtCommand({ query, config: mockConfig, - addItem: mockAddItem, onDebugMessage: mockOnDebugMessage, messageId, signal: abortController.signal, }); - expect(result).toEqual({ + expect(result).toMatchObject({ processedQuery: [ { text: query }, { text: '\n--- Content from referenced files ---' }, - { text: `\nContent from @${filePath}:\n` }, + { text: `\nContent from ${filePath}:\n` }, { text: fileContent }, { text: '\n--- End of content ---' }, ], @@ -826,19 +763,18 @@ describe('handleAtCommand', () => { const result = await handleAtCommand({ query, config: mockConfig, - addItem: mockAddItem, onDebugMessage: mockOnDebugMessage, messageId: 411, signal: abortController.signal, }); - expect(result).toEqual({ + expect(result).toMatchObject({ processedQuery: [ { text: `Compare @${file1Path}, @${file2Path}; what's different?` }, { text: '\n--- Content from referenced files ---' }, - { text: `\nContent from @${file1Path}:\n` }, + { text: `\nContent from ${file1Path}:\n` }, { text: content1 }, - { text: `\nContent from @${file2Path}:\n` }, + { text: `\nContent from ${file2Path}:\n` }, { text: content2 }, { text: '\n--- End of content ---' }, ], @@ -858,17 +794,16 @@ describe('handleAtCommand', () => { const result = await handleAtCommand({ query, config: mockConfig, - addItem: mockAddItem, onDebugMessage: mockOnDebugMessage, messageId: 412, signal: abortController.signal, }); - expect(result).toEqual({ + expect(result).toMatchObject({ processedQuery: [ { text: `Check @${filePath}, it has spaces.` }, { text: '\n--- Content from referenced files ---' }, - { text: `\nContent from @${filePath}:\n` }, + { text: `\nContent from ${filePath}:\n` }, { text: fileContent }, { text: '\n--- End of content ---' }, ], @@ -887,17 +822,16 @@ describe('handleAtCommand', () => { const result = await handleAtCommand({ query, config: mockConfig, - addItem: mockAddItem, onDebugMessage: mockOnDebugMessage, messageId: 413, signal: abortController.signal, }); - expect(result).toEqual({ + expect(result).toMatchObject({ processedQuery: [ { text: `Analyze @${filePath} for type definitions.` }, { text: '\n--- Content from referenced files ---' }, - { text: `\nContent from @${filePath}:\n` }, + { text: `\nContent from ${filePath}:\n` }, { text: fileContent }, { text: '\n--- End of content ---' }, ], @@ -916,17 +850,16 @@ describe('handleAtCommand', () => { const result = await handleAtCommand({ query, config: mockConfig, - addItem: mockAddItem, onDebugMessage: mockOnDebugMessage, messageId: 414, signal: abortController.signal, }); - expect(result).toEqual({ + expect(result).toMatchObject({ processedQuery: [ { text: `Check @${filePath}. This file contains settings.` }, { text: '\n--- Content from referenced files ---' }, - { text: `\nContent from @${filePath}:\n` }, + { text: `\nContent from ${filePath}:\n` }, { text: fileContent }, { text: '\n--- End of content ---' }, ], @@ -945,17 +878,16 @@ describe('handleAtCommand', () => { const result = await handleAtCommand({ query, config: mockConfig, - addItem: mockAddItem, onDebugMessage: mockOnDebugMessage, messageId: 415, signal: abortController.signal, }); - expect(result).toEqual({ + expect(result).toMatchObject({ processedQuery: [ { text: `Review @${filePath}, then check dependencies.` }, { text: '\n--- Content from referenced files ---' }, - { text: `\nContent from @${filePath}:\n` }, + { text: `\nContent from ${filePath}:\n` }, { text: fileContent }, { text: '\n--- End of content ---' }, ], @@ -974,17 +906,16 @@ describe('handleAtCommand', () => { const result = await handleAtCommand({ query, config: mockConfig, - addItem: mockAddItem, onDebugMessage: mockOnDebugMessage, messageId: 416, signal: abortController.signal, }); - expect(result).toEqual({ + expect(result).toMatchObject({ processedQuery: [ { text: `Check @${filePath} contains version information.` }, { text: '\n--- Content from referenced files ---' }, - { text: `\nContent from @${filePath}:\n` }, + { text: `\nContent from ${filePath}:\n` }, { text: fileContent }, { text: '\n--- End of content ---' }, ], @@ -1003,17 +934,16 @@ describe('handleAtCommand', () => { const result = await handleAtCommand({ query, config: mockConfig, - addItem: mockAddItem, onDebugMessage: mockOnDebugMessage, messageId: 417, signal: abortController.signal, }); - expect(result).toEqual({ + expect(result).toMatchObject({ processedQuery: [ { text: `Show me @${filePath}.` }, { text: '\n--- Content from referenced files ---' }, - { text: `\nContent from @${filePath}:\n` }, + { text: `\nContent from ${filePath}:\n` }, { text: fileContent }, { text: '\n--- End of content ---' }, ], @@ -1032,17 +962,16 @@ describe('handleAtCommand', () => { const result = await handleAtCommand({ query, config: mockConfig, - addItem: mockAddItem, onDebugMessage: mockOnDebugMessage, messageId: 418, signal: abortController.signal, }); - expect(result).toEqual({ + expect(result).toMatchObject({ processedQuery: [ { text: `Check @${filePath} for content.` }, { text: '\n--- Content from referenced files ---' }, - { text: `\nContent from @${filePath}:\n` }, + { text: `\nContent from ${filePath}:\n` }, { text: fileContent }, { text: '\n--- End of content ---' }, ], @@ -1061,17 +990,16 @@ describe('handleAtCommand', () => { const result = await handleAtCommand({ query, config: mockConfig, - addItem: mockAddItem, onDebugMessage: mockOnDebugMessage, messageId: 421, signal: abortController.signal, }); - expect(result).toEqual({ + expect(result).toMatchObject({ processedQuery: [ { text: `Check @${filePath} please.` }, { text: '\n--- Content from referenced files ---' }, - { text: `\nContent from @${filePath}:\n` }, + { text: `\nContent from ${filePath}:\n` }, { text: fileContent }, { text: '\n--- End of content ---' }, ], @@ -1080,7 +1008,7 @@ describe('handleAtCommand', () => { }); }); - it("should not add the user's turn to history, as that is the caller's responsibility", async () => { + it("should not add any items to history, as that is the caller's responsibility", async () => { // Arrange const fileContent = 'This is the file content.'; const filePath = await createTestFile( @@ -1090,26 +1018,119 @@ describe('handleAtCommand', () => { const query = `A query with @${filePath}`; // Act - await handleAtCommand({ + const result = await handleAtCommand({ query, config: mockConfig, - addItem: mockAddItem, onDebugMessage: mockOnDebugMessage, messageId: 999, signal: abortController.signal, }); // Assert - // It SHOULD be called for the tool_group - expect(mockAddItem).toHaveBeenCalledWith( - expect.objectContaining({ type: 'tool_group' }), - 999, - ); + // handleAtCommand should NOT call addItem at all - it returns data for caller to add + expect(mockAddItem).not.toHaveBeenCalled(); - // It should NOT have been called for the user turn - const userTurnCalls = mockAddItem.mock.calls.filter( - (call) => call[0].type === 'user', - ); - expect(userTurnCalls).toHaveLength(0); + // Instead, it returns toolDisplays for the caller to add to UI history + expect(result.toolDisplays).toBeDefined(); + expect(result.toolDisplays!.length).toBeGreaterThan(0); + }); + + describe('chat recording', () => { + it('should return tool result info for each file read', async () => { + const content1 = 'Content file1'; + const file1Path = await createTestFile( + path.join(testRootDir, 'file1.txt'), + content1, + ); + const content2 = 'Content file2'; + const file2Path = await createTestFile( + path.join(testRootDir, 'file2.txt'), + content2, + ); + const query = `@${file1Path} @${file2Path}`; + + const result = await handleAtCommand({ + query, + config: mockConfig, + onDebugMessage: mockOnDebugMessage, + messageId: 500, + signal: abortController.signal, + }); + + // Should return toolDisplays (one summary for all files) + expect(result.toolDisplays).toBeDefined(); + expect(result.toolDisplays!.length).toBeGreaterThanOrEqual(1); + }); + + it('should return toolDisplays for UI and function parts in processedQuery', async () => { + const fileContent = 'Test content'; + const filePath = await createTestFile( + path.join(testRootDir, 'test.txt'), + fileContent, + ); + const query = `@${filePath}`; + + const result = await handleAtCommand({ + query, + config: mockConfig, + onDebugMessage: mockOnDebugMessage, + messageId: 501, + signal: abortController.signal, + }); + + // Should return toolDisplays for UI + expect(result.toolDisplays).toBeDefined(); + expect(result.toolDisplays!.length).toBeGreaterThanOrEqual(1); + + // processedQuery should include file content sections + expect(result.processedQuery).toBeDefined(); + const parts = Array.isArray(result.processedQuery) + ? result.processedQuery + : [result.processedQuery]; + const flattened = parts + .map((part) => + typeof part === 'string' + ? part + : (part as { text?: string }).text || '', + ) + .join(''); + expect(flattened).toContain('Content from '); + expect(flattened).toContain(fileContent); + }); + + it('should not return tool result infos when no files are read', async () => { + const query = 'query without any @ commands'; + + const result = await handleAtCommand({ + query, + config: mockConfig, + onDebugMessage: mockOnDebugMessage, + messageId: 502, + signal: abortController.signal, + }); + + expect(result.toolDisplays).toBeUndefined(); + }); + + it('should include file path in tool display result', async () => { + const fileContent = 'File content here'; + const filePath = await createTestFile( + path.join(testRootDir, 'specific-file.txt'), + fileContent, + ); + const query = `@${filePath}`; + + const result = await handleAtCommand({ + query, + config: mockConfig, + onDebugMessage: mockOnDebugMessage, + messageId: 503, + signal: abortController.signal, + }); + + expect(result.toolDisplays).toBeDefined(); + expect(result.toolDisplays!.length).toBeGreaterThanOrEqual(1); + expect(result.toolDisplays![0].description).toContain('file.txt'); + }); }); }); diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.ts b/packages/cli/src/ui/hooks/atCommandProcessor.ts index f3e41956b..e423a5f9c 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.ts @@ -6,29 +6,35 @@ import * as fs from 'node:fs/promises'; import * as path from 'node:path'; -import type { PartListUnion, PartUnion } from '@google/genai'; -import type { AnyToolInvocation, Config } from '@qwen-code/qwen-code-core'; +import type { PartListUnion } from '@google/genai'; +import type { Config } from '@qwen-code/qwen-code-core'; import { getErrorMessage, isNodeError, unescapePath, + readManyFiles, } from '@qwen-code/qwen-code-core'; -import type { HistoryItem, IndividualToolCallDisplay } from '../types.js'; +import type { + HistoryItemToolGroup, + HistoryItemWithoutId, + IndividualToolCallDisplay, +} from '../types.js'; import { ToolCallStatus } from '../types.js'; -import type { UseHistoryManagerReturn } from './useHistoryManager.js'; interface HandleAtCommandParams { query: string; config: Config; - addItem: UseHistoryManagerReturn['addItem']; onDebugMessage: (message: string) => void; messageId: number; signal: AbortSignal; + addItem?: (item: HistoryItemWithoutId, baseTimestamp: number) => number; } interface HandleAtCommandResult { processedQuery: PartListUnion | null; shouldProceed: boolean; + toolDisplays?: IndividualToolCallDisplay[]; + filesRead?: string[]; } interface AtCommandPart { @@ -122,16 +128,27 @@ function parseAllAtCommands(query: string): AtCommandPart[] { export async function handleAtCommand({ query, config, - addItem, onDebugMessage, messageId: userMessageTimestamp, signal, + addItem, }: HandleAtCommandParams): Promise { const commandParts = parseAllAtCommands(query); const atPathCommandParts = commandParts.filter( (part) => part.type === 'atPath', ); + const addToolGroup = (result: HandleAtCommandResult): void => { + if (!addItem) return; + if (result.toolDisplays && result.toolDisplays.length > 0) { + const toolGroupItem: HistoryItemToolGroup = { + type: 'tool_group', + tools: result.toolDisplays, + }; + addItem(toolGroupItem, userMessageTimestamp); + } + }; + if (atPathCommandParts.length === 0) { return { processedQuery: [{ text: query }], shouldProceed: true }; } @@ -150,18 +167,6 @@ export async function handleAtCommand({ both: [], }; - const toolRegistry = config.getToolRegistry(); - const readManyFilesTool = toolRegistry.getTool('read_many_files'); - const globTool = toolRegistry.getTool('glob'); - - if (!readManyFilesTool) { - addItem( - { type: 'error', text: 'Error: read_many_files tool not found.' }, - userMessageTimestamp, - ); - return { processedQuery: null, shouldProceed: false }; - } - for (const atPathPart of atPathCommandParts) { const originalAtPath = atPathPart.content; // e.g., "@file.txt" or "@" @@ -173,23 +178,8 @@ export async function handleAtCommand({ } const pathName = originalAtPath.substring(1); - if (!pathName) { - // This case should ideally not be hit if parseAllAtCommands ensures content after @ - // but as a safeguard: - addItem( - { - type: 'error', - text: `Error: Invalid @ command '${originalAtPath}'. No path specified.`, - }, - userMessageTimestamp, - ); - // Decide if this is a fatal error for the whole command or just skip this @ part - // For now, let's be strict and fail the command if one @path is malformed. - return { processedQuery: null, shouldProceed: false }; - } // Check if path should be ignored based on filtering options - const workspaceContext = config.getWorkspaceContext(); if (!workspaceContext.isPathWithinWorkspace(pathName)) { onDebugMessage( @@ -225,73 +215,24 @@ export async function handleAtCommand({ continue; } + let resolvedSuccessfully = false; + let sawNotFound = false; for (const dir of config.getWorkspaceContext().getDirectories()) { let currentPathSpec = pathName; - let resolvedSuccessfully = false; try { const absolutePath = path.resolve(dir, pathName); const stats = await fs.stat(absolutePath); if (stats.isDirectory()) { - currentPathSpec = - pathName + (pathName.endsWith(path.sep) ? `**` : `/**`); - onDebugMessage( - `Path ${pathName} resolved to directory, using glob: ${currentPathSpec}`, - ); + currentPathSpec = pathName; + onDebugMessage(`Path ${pathName} resolved to directory.`); } else { onDebugMessage(`Path ${pathName} resolved to file: ${absolutePath}`); } resolvedSuccessfully = true; } catch (error) { if (isNodeError(error) && error.code === 'ENOENT') { - if (config.getEnableRecursiveFileSearch() && globTool) { - onDebugMessage( - `Path ${pathName} not found directly, attempting glob search.`, - ); - try { - const globResult = await globTool.buildAndExecute( - { - pattern: `**/*${pathName}*`, - path: dir, - }, - signal, - ); - if ( - globResult.llmContent && - typeof globResult.llmContent === 'string' && - !globResult.llmContent.startsWith('No files found') && - !globResult.llmContent.startsWith('Error:') - ) { - const lines = globResult.llmContent.split('\n'); - if (lines.length > 1 && lines[1]) { - const firstMatchAbsolute = lines[1].trim(); - currentPathSpec = path.relative(dir, firstMatchAbsolute); - onDebugMessage( - `Glob search for ${pathName} found ${firstMatchAbsolute}, using relative path: ${currentPathSpec}`, - ); - resolvedSuccessfully = true; - } else { - onDebugMessage( - `Glob search for '**/*${pathName}*' did not return a usable path. Path ${pathName} will be skipped.`, - ); - } - } else { - onDebugMessage( - `Glob search for '**/*${pathName}*' found no files or an error. Path ${pathName} will be skipped.`, - ); - } - } catch (globError) { - console.error( - `Error during glob search for ${pathName}: ${getErrorMessage(globError)}`, - ); - onDebugMessage( - `Error during glob search for ${pathName}. Path ${pathName} will be skipped.`, - ); - } - } else { - onDebugMessage( - `Glob tool not found. Path ${pathName} will be skipped.`, - ); - } + sawNotFound = true; + continue; } else { console.error( `Error stating path ${pathName}: ${getErrorMessage(error)}`, @@ -308,6 +249,11 @@ export async function handleAtCommand({ break; } } + if (!resolvedSuccessfully && sawNotFound) { + onDebugMessage( + `Path ${pathName} not found. Path ${pathName} will be skipped.`, + ); + } } // Construct the initial part of the query for the LLM @@ -393,88 +339,89 @@ export async function handleAtCommand({ }; } - const processedQueryParts: PartUnion[] = [{ text: initialQueryText }]; - - const toolArgs = { - paths: pathSpecsToRead, - file_filtering_options: { - respect_git_ignore: respectFileIgnore.respectGitIgnore, - respect_qwen_ignore: respectFileIgnore.respectQwenIgnore, - }, - // Use configuration setting - }; - let toolCallDisplay: IndividualToolCallDisplay; - - let invocation: AnyToolInvocation | undefined = undefined; try { - invocation = readManyFilesTool.build(toolArgs); - const result = await invocation.execute(signal); - toolCallDisplay = { - callId: `client-read-${userMessageTimestamp}`, - name: readManyFilesTool.displayName, - description: invocation.getDescription(), - status: ToolCallStatus.Success, - resultDisplay: - result.returnDisplay || - `Successfully read: ${contentLabelsForDisplay.join(', ')}`, - confirmationDetails: undefined, - }; + const result = await readManyFiles(config, { + paths: pathSpecsToRead, + signal, + }); - if (Array.isArray(result.llmContent)) { - const fileContentRegex = /^--- (.*?) ---\n\n([\s\S]*?)\n\n$/; - processedQueryParts.push({ - text: '\n--- Content from referenced files ---', - }); - for (const part of result.llmContent) { + const parts = Array.isArray(result.contentParts) + ? result.contentParts + : [result.contentParts]; + + // Create individual tool call displays for each file read + const toolCallDisplays: IndividualToolCallDisplay[] = result.files.map( + (file, index) => ({ + callId: `client-read-${userMessageTimestamp}-${index}`, + name: file.isDirectory ? 'Read Directory' : 'Read File', + description: file.isDirectory + ? `Read directory ${path.basename(file.filePath)}` + : `Read file ${path.basename(file.filePath)}`, + status: ToolCallStatus.Success, + resultDisplay: undefined, + confirmationDetails: undefined, + }), + ); + + const processedQueryParts: PartListUnion = [{ text: initialQueryText }]; + + if (parts.length > 0 && !result.error) { + // readManyFiles now returns properly formatted parts with headers and prefixes + for (const part of parts) { if (typeof part === 'string') { - const match = fileContentRegex.exec(part); - if (match) { - const filePathSpecInContent = match[1]; // This is a resolved pathSpec - const fileActualContent = match[2].trim(); - processedQueryParts.push({ - text: `\nContent from @${filePathSpecInContent}:\n`, - }); - processedQueryParts.push({ text: fileActualContent }); - } else { - processedQueryParts.push({ text: part }); - } + processedQueryParts.push({ text: part }); } else { - // part is a Part object. + // part is a Part object (text, inlineData, or fileData) processedQueryParts.push(part); } } } else { - onDebugMessage( - 'read_many_files tool returned no content or empty content.', - ); + onDebugMessage('readManyFiles returned no content or empty content.'); } - addItem( - { type: 'tool_group', tools: [toolCallDisplay] } as Omit< - HistoryItem, - 'id' - >, - userMessageTimestamp, - ); - return { processedQuery: processedQueryParts, shouldProceed: true }; + const processedResult: HandleAtCommandResult = { + processedQuery: processedQueryParts, + shouldProceed: true, + toolDisplays: toolCallDisplays, + filesRead: contentLabelsForDisplay, + }; + + const chatRecorder = config.getChatRecordingService?.(); + chatRecorder?.recordAtCommand({ + filesRead: contentLabelsForDisplay, + status: 'success', + userText: query, + }); + + addToolGroup(processedResult); + return processedResult; } catch (error: unknown) { - toolCallDisplay = { + const errorToolCallDisplay: IndividualToolCallDisplay = { callId: `client-read-${userMessageTimestamp}`, - name: readManyFilesTool.displayName, - description: - invocation?.getDescription() ?? - 'Error attempting to execute tool to read files', + name: 'Read File(s)', + description: 'Error attempting to read files', status: ToolCallStatus.Error, resultDisplay: `Error reading files (${contentLabelsForDisplay.join(', ')}): ${getErrorMessage(error)}`, confirmationDetails: undefined, }; - addItem( - { type: 'tool_group', tools: [toolCallDisplay] } as Omit< - HistoryItem, - 'id' - >, - userMessageTimestamp, - ); - return { processedQuery: null, shouldProceed: false }; + const chatRecorder = config.getChatRecordingService?.(); + const errorMessage = + typeof errorToolCallDisplay.resultDisplay === 'string' + ? errorToolCallDisplay.resultDisplay + : undefined; + chatRecorder?.recordAtCommand({ + filesRead: contentLabelsForDisplay, + status: 'error', + message: errorMessage, + userText: query, + }); + const result = { + processedQuery: null, + shouldProceed: false, + toolDisplays: [errorToolCallDisplay], + filesRead: contentLabelsForDisplay, + }; + addToolGroup(result); + return result; } } diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 561c98ed6..3d114d1ec 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -382,34 +382,28 @@ export const useGeminiStream = ( return { queryToSend: null, shouldProceed: false }; } + localQueryToSendToGemini = trimmedQuery; + + addItem( + { type: MessageType.USER, text: trimmedQuery }, + userMessageTimestamp, + ); + // Handle @-commands (which might involve tool calls) if (isAtCommand(trimmedQuery)) { const atCommandResult = await handleAtCommand({ query: trimmedQuery, config, - addItem, onDebugMessage, messageId: userMessageTimestamp, signal: abortSignal, + addItem, }); - // Add user's turn after @ command processing is done. - addItem( - { type: MessageType.USER, text: trimmedQuery }, - userMessageTimestamp, - ); - if (!atCommandResult.shouldProceed) { return { queryToSend: null, shouldProceed: false }; } localQueryToSendToGemini = atCommandResult.processedQuery; - } else { - // Normal query for Gemini - addItem( - { type: MessageType.USER, text: trimmedQuery }, - userMessageTimestamp, - ); - localQueryToSendToGemini = trimmedQuery; } } else { // It's a function response (PartListUnion that isn't a string) @@ -981,6 +975,7 @@ export const useGeminiStream = ( prompt_id!, options, ); + const processingStatus = await processGeminiStreamEvents( stream, userMessageTimestamp, diff --git a/packages/cli/src/ui/utils/resumeHistoryUtils.ts b/packages/cli/src/ui/utils/resumeHistoryUtils.ts index 686e87e2d..3e6607b04 100644 --- a/packages/cli/src/ui/utils/resumeHistoryUtils.ts +++ b/packages/cli/src/ui/utils/resumeHistoryUtils.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import * as path from 'node:path'; import type { Part, FunctionCall } from '@google/genai'; import type { ResumedSessionData, @@ -12,8 +13,13 @@ import type { AnyDeclarativeTool, ToolResultDisplay, SlashCommandRecordPayload, + AtCommandRecordPayload, } from '@qwen-code/qwen-code-core'; -import type { HistoryItem, HistoryItemWithoutId } from '../types.js'; +import type { + HistoryItem, + HistoryItemWithoutId, + IndividualToolCallDisplay, +} from '../types.js'; import { ToolCallStatus } from '../types.js'; /** @@ -137,6 +143,8 @@ function convertToHistoryItems( config: Config, ): HistoryItemWithoutId[] { const items: HistoryItemWithoutId[] = []; + const pendingAtCommands: AtCommandRecordPayload[] = []; + let atCommandCounter = 0; // Track pending tool calls for grouping with results const pendingToolCalls = new Map< @@ -152,6 +160,59 @@ function convertToHistoryItems( confirmationDetails: undefined; }> = []; + const buildAtCommandDisplays = ( + payload: AtCommandRecordPayload, + ): IndividualToolCallDisplay[] => { + // Error case: single "Read File(s)" with error message + if (payload.status === 'error') { + atCommandCounter += 1; + const filesLabel = payload.filesRead?.length + ? payload.filesRead.join(', ') + : 'files'; + return [ + { + callId: `at-command-${atCommandCounter}`, + name: 'Read File(s)', + description: 'Error attempting to read files', + status: ToolCallStatus.Error, + resultDisplay: + payload.message || `Error reading files (${filesLabel})`, + confirmationDetails: undefined, + }, + ]; + } + + // Success case: individual tool calls for each file + if (!payload.filesRead?.length) { + atCommandCounter += 1; + return [ + { + callId: `at-command-${atCommandCounter}`, + name: 'Read File', + description: 'Read File(s)', + status: ToolCallStatus.Success, + resultDisplay: undefined, + confirmationDetails: undefined, + }, + ]; + } + + return payload.filesRead.map((filePath) => { + atCommandCounter += 1; + const isDir = filePath.endsWith('/'); + return { + callId: `at-command-${atCommandCounter}`, + name: isDir ? 'Read Directory' : 'Read File', + description: isDir + ? `Read directory ${path.basename(filePath)}` + : `Read file ${path.basename(filePath)}`, + status: ToolCallStatus.Success, + resultDisplay: undefined, + confirmationDetails: undefined, + }; + }); + }; + for (const record of conversation.messages) { if (record.type === 'system') { if (record.subtype === 'slash_command') { @@ -180,10 +241,44 @@ function convertToHistoryItems( } } } + if (record.subtype === 'at_command') { + const payload = record.systemPayload as + | AtCommandRecordPayload + | undefined; + if (!payload) continue; + pendingAtCommands.push(payload); + } continue; } switch (record.type) { case 'user': { + if (pendingAtCommands.length > 0) { + // Flush any pending tool group before user message + if (currentToolGroup.length > 0) { + items.push({ + type: 'tool_group', + tools: [...currentToolGroup], + }); + currentToolGroup = []; + } + + const payload = pendingAtCommands.shift()!; + const text = + payload.userText || + extractTextFromParts(record.message?.parts as Part[]); + if (text) { + items.push({ type: 'user', text }); + } + + const toolDisplays = buildAtCommandDisplays(payload); + if (toolDisplays.length > 0) { + items.push({ + type: 'tool_group', + tools: toolDisplays, + }); + } + break; + } // Flush any pending tool group before user message if (currentToolGroup.length > 0) { items.push({ @@ -290,6 +385,31 @@ function convertToHistoryItems( } } + if (pendingAtCommands.length > 0) { + for (const payload of pendingAtCommands) { + // Flush any pending tool group before standalone @-command + if (currentToolGroup.length > 0) { + items.push({ + type: 'tool_group', + tools: [...currentToolGroup], + }); + currentToolGroup = []; + } + + const text = payload.userText; + if (text) { + items.push({ type: 'user', text }); + } + const toolDisplays = buildAtCommandDisplays(payload); + if (toolDisplays.length > 0) { + items.push({ + type: 'tool_group', + tools: toolDisplays, + }); + } + } + } + // Flush any remaining tool group if (currentToolGroup.length > 0) { items.push({ diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index e6a87941e..08498e28b 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -206,7 +206,6 @@ describe('Server Config (config.ts)', () => { const TARGET_DIR = '/path/to/target'; const DEBUG_MODE = false; const QUESTION = 'test question'; - const FULL_CONTEXT = false; const USER_MEMORY = 'Test User Memory'; const TELEMETRY_SETTINGS = { enabled: false }; const EMBEDDING_MODEL = 'gemini-embedding'; @@ -217,7 +216,6 @@ describe('Server Config (config.ts)', () => { targetDir: TARGET_DIR, debugMode: DEBUG_MODE, question: QUESTION, - fullContext: FULL_CONTEXT, userMemory: USER_MEMORY, telemetry: TELEMETRY_SETTINGS, model: MODEL, @@ -1266,7 +1264,6 @@ describe('BaseLlmClient Lifecycle', () => { const TARGET_DIR = '/path/to/target'; const DEBUG_MODE = false; const QUESTION = 'test question'; - const FULL_CONTEXT = false; const USER_MEMORY = 'Test User Memory'; const TELEMETRY_SETTINGS = { enabled: false }; const EMBEDDING_MODEL = 'gemini-embedding'; @@ -1277,7 +1274,6 @@ describe('BaseLlmClient Lifecycle', () => { targetDir: TARGET_DIR, debugMode: DEBUG_MODE, question: QUESTION, - fullContext: FULL_CONTEXT, userMemory: USER_MEMORY, telemetry: TELEMETRY_SETTINGS, model: MODEL, diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index af2d28555..47a3351bf 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -48,7 +48,6 @@ import { LSTool } from '../tools/ls.js'; import type { SendSdkMcpMessage } from '../tools/mcp-client.js'; import { MemoryTool, setGeminiMdFilename } from '../tools/memoryTool.js'; import { ReadFileTool } from '../tools/read-file.js'; -import { ReadManyFilesTool } from '../tools/read-many-files.js'; import { canUseRipgrep } from '../utils/ripgrepUtils.js'; import { RipGrepTool } from '../tools/ripGrep.js'; import { ShellTool } from '../tools/shell.js'; @@ -280,7 +279,6 @@ export interface ConfigParameters { debugMode: boolean; includePartialMessages?: boolean; question?: string; - fullContext?: boolean; coreTools?: string[]; allowedTools?: string[]; excludeTools?: string[]; @@ -427,7 +425,6 @@ export class Config { private readonly outputFormat: OutputFormat; private readonly includePartialMessages: boolean; private readonly question: string | undefined; - private readonly fullContext: boolean; private readonly coreTools: string[] | undefined; private readonly allowedTools: string[] | undefined; private readonly excludeTools: string[] | undefined; @@ -532,7 +529,6 @@ export class Config { this.outputFormat = normalizedOutputFormat ?? OutputFormat.TEXT; this.includePartialMessages = params.includePartialMessages ?? false; this.question = params.question; - this.fullContext = params.fullContext ?? false; this.coreTools = params.coreTools; this.allowedTools = params.allowedTools; this.excludeTools = params.excludeTools; @@ -1039,10 +1035,6 @@ export class Config { return this.question; } - getFullContext(): boolean { - return this.fullContext; - } - getCoreTools(): string[] | undefined { return this.coreTools; } @@ -1655,7 +1647,6 @@ export class Config { registerCoreTool(EditTool, this); } registerCoreTool(WriteFileTool, this); - registerCoreTool(ReadManyFilesTool, this); registerCoreTool(ShellTool, this); registerCoreTool(MemoryTool); registerCoreTool(TodoWriteTool, this); diff --git a/packages/core/src/core/__snapshots__/prompts.test.ts.snap b/packages/core/src/core/__snapshots__/prompts.test.ts.snap index 0c0b6c6ad..5a2845d9b 100644 --- a/packages/core/src/core/__snapshots__/prompts.test.ts.snap +++ b/packages/core/src/core/__snapshots__/prompts.test.ts.snap @@ -69,7 +69,7 @@ I've found some existing telemetry code. Let me mark the first todo as in_progre ## Software Engineering Tasks When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this iterative approach: - **Plan:** After understanding the user's request, create an initial plan based on your existing knowledge and any immediately obvious context. Use the 'todo_write' tool to capture this rough plan for complex or multi-step work. Don't wait for complete understanding - start with what you know. -- **Implement:** Begin implementing the plan while gathering additional context as needed. Use 'grep_search', 'glob', 'read_file', and 'read_many_files' tools strategically when you encounter specific unknowns during implementation. Use the available tools (e.g., 'edit', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates'). +- **Implement:** Begin implementing the plan while gathering additional context as needed. Use 'grep_search', 'glob', and 'read_file' tools strategically when you encounter specific unknowns during implementation. Use the available tools (e.g., 'edit', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates'). - **Adapt:** As you discover new information or encounter obstacles, update your plan and todos accordingly. Mark todos as in_progress when starting and completed when finishing each task. Add new todos if the scope expands. Refine your approach based on what you learn. - **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands. - **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. If unsure about these commands, you can ask the user if they'd like you to run them and if so how to. @@ -190,7 +190,7 @@ model: Okay, I can write those tests. First, I'll read someFile.ts to understand its functionality. [tool_call: read_file for path '/path/to/someFile.ts'] Now I'll look for existing or related test files to understand current testing conventions and dependencies. -[tool_call: read_many_files for paths ['**/*.test.ts', 'src/**/*.spec.ts']] +[tool_call: read_file for path '/path/to/existingTest.test.ts'] (After reviewing existing tests and the file content) [tool_call: write_file for path '/path/to/someFile.test.ts'] I've written the tests. Now I'll run the project's test command to verify them. @@ -211,7 +211,7 @@ To help you check their settings, I can read their contents. Which one would you # Final Reminder -Your core function is efficient and safe assistance. Balance extreme conciseness with the crucial need for clarity, especially regarding safety and potential system modifications. Always prioritize user control and project conventions. Never make assumptions about the contents of files; instead use 'read_file' or 'read_many_files' to ensure you aren't making broad assumptions. Finally, you are an agent - please keep going until the user's query is completely resolved. +Your core function is efficient and safe assistance. Balance extreme conciseness with the crucial need for clarity, especially regarding safety and potential system modifications. Always prioritize user control and project conventions. Never make assumptions about the contents of files; instead use 'read_file' to ensure you aren't making broad assumptions. Finally, you are an agent - please keep going until the user's query is completely resolved. --- @@ -288,7 +288,7 @@ I've found some existing telemetry code. Let me mark the first todo as in_progre ## Software Engineering Tasks When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this iterative approach: - **Plan:** After understanding the user's request, create an initial plan based on your existing knowledge and any immediately obvious context. Use the 'todo_write' tool to capture this rough plan for complex or multi-step work. Don't wait for complete understanding - start with what you know. -- **Implement:** Begin implementing the plan while gathering additional context as needed. Use 'grep_search', 'glob', 'read_file', and 'read_many_files' tools strategically when you encounter specific unknowns during implementation. Use the available tools (e.g., 'edit', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates'). +- **Implement:** Begin implementing the plan while gathering additional context as needed. Use 'grep_search', 'glob', and 'read_file' tools strategically when you encounter specific unknowns during implementation. Use the available tools (e.g., 'edit', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates'). - **Adapt:** As you discover new information or encounter obstacles, update your plan and todos accordingly. Mark todos as in_progress when starting and completed when finishing each task. Add new todos if the scope expands. Refine your approach based on what you learn. - **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands. - **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. If unsure about these commands, you can ask the user if they'd like you to run them and if so how to. @@ -424,7 +424,7 @@ model: Okay, I can write those tests. First, I'll read someFile.ts to understand its functionality. [tool_call: read_file for path '/path/to/someFile.ts'] Now I'll look for existing or related test files to understand current testing conventions and dependencies. -[tool_call: read_many_files for paths ['**/*.test.ts', 'src/**/*.spec.ts']] +[tool_call: read_file for path '/path/to/existingTest.test.ts'] (After reviewing existing tests and the file content) [tool_call: write_file for path '/path/to/someFile.test.ts'] I've written the tests. Now I'll run the project's test command to verify them. @@ -445,7 +445,7 @@ To help you check their settings, I can read their contents. Which one would you # Final Reminder -Your core function is efficient and safe assistance. Balance extreme conciseness with the crucial need for clarity, especially regarding safety and potential system modifications. Always prioritize user control and project conventions. Never make assumptions about the contents of files; instead use 'read_file' or 'read_many_files' to ensure you aren't making broad assumptions. Finally, you are an agent - please keep going until the user's query is completely resolved." +Your core function is efficient and safe assistance. Balance extreme conciseness with the crucial need for clarity, especially regarding safety and potential system modifications. Always prioritize user control and project conventions. Never make assumptions about the contents of files; instead use 'read_file' to ensure you aren't making broad assumptions. Finally, you are an agent - please keep going until the user's query is completely resolved." `; exports[`Core System Prompt (prompts.ts) > should include non-sandbox instructions when SANDBOX env var is not set 1`] = ` @@ -517,7 +517,7 @@ I've found some existing telemetry code. Let me mark the first todo as in_progre ## Software Engineering Tasks When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this iterative approach: - **Plan:** After understanding the user's request, create an initial plan based on your existing knowledge and any immediately obvious context. Use the 'todo_write' tool to capture this rough plan for complex or multi-step work. Don't wait for complete understanding - start with what you know. -- **Implement:** Begin implementing the plan while gathering additional context as needed. Use 'grep_search', 'glob', 'read_file', and 'read_many_files' tools strategically when you encounter specific unknowns during implementation. Use the available tools (e.g., 'edit', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates'). +- **Implement:** Begin implementing the plan while gathering additional context as needed. Use 'grep_search', 'glob', and 'read_file' tools strategically when you encounter specific unknowns during implementation. Use the available tools (e.g., 'edit', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates'). - **Adapt:** As you discover new information or encounter obstacles, update your plan and todos accordingly. Mark todos as in_progress when starting and completed when finishing each task. Add new todos if the scope expands. Refine your approach based on what you learn. - **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands. - **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. If unsure about these commands, you can ask the user if they'd like you to run them and if so how to. @@ -638,7 +638,7 @@ model: Okay, I can write those tests. First, I'll read someFile.ts to understand its functionality. [tool_call: read_file for path '/path/to/someFile.ts'] Now I'll look for existing or related test files to understand current testing conventions and dependencies. -[tool_call: read_many_files for paths ['**/*.test.ts', 'src/**/*.spec.ts']] +[tool_call: read_file for path '/path/to/existingTest.test.ts'] (After reviewing existing tests and the file content) [tool_call: write_file for path '/path/to/someFile.test.ts'] I've written the tests. Now I'll run the project's test command to verify them. @@ -659,7 +659,7 @@ To help you check their settings, I can read their contents. Which one would you # Final Reminder -Your core function is efficient and safe assistance. Balance extreme conciseness with the crucial need for clarity, especially regarding safety and potential system modifications. Always prioritize user control and project conventions. Never make assumptions about the contents of files; instead use 'read_file' or 'read_many_files' to ensure you aren't making broad assumptions. Finally, you are an agent - please keep going until the user's query is completely resolved." +Your core function is efficient and safe assistance. Balance extreme conciseness with the crucial need for clarity, especially regarding safety and potential system modifications. Always prioritize user control and project conventions. Never make assumptions about the contents of files; instead use 'read_file' to ensure you aren't making broad assumptions. Finally, you are an agent - please keep going until the user's query is completely resolved." `; exports[`Core System Prompt (prompts.ts) > should include sandbox-specific instructions when SANDBOX env var is set 1`] = ` @@ -731,7 +731,7 @@ I've found some existing telemetry code. Let me mark the first todo as in_progre ## Software Engineering Tasks When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this iterative approach: - **Plan:** After understanding the user's request, create an initial plan based on your existing knowledge and any immediately obvious context. Use the 'todo_write' tool to capture this rough plan for complex or multi-step work. Don't wait for complete understanding - start with what you know. -- **Implement:** Begin implementing the plan while gathering additional context as needed. Use 'grep_search', 'glob', 'read_file', and 'read_many_files' tools strategically when you encounter specific unknowns during implementation. Use the available tools (e.g., 'edit', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates'). +- **Implement:** Begin implementing the plan while gathering additional context as needed. Use 'grep_search', 'glob', and 'read_file' tools strategically when you encounter specific unknowns during implementation. Use the available tools (e.g., 'edit', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates'). - **Adapt:** As you discover new information or encounter obstacles, update your plan and todos accordingly. Mark todos as in_progress when starting and completed when finishing each task. Add new todos if the scope expands. Refine your approach based on what you learn. - **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands. - **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. If unsure about these commands, you can ask the user if they'd like you to run them and if so how to. @@ -852,7 +852,7 @@ model: Okay, I can write those tests. First, I'll read someFile.ts to understand its functionality. [tool_call: read_file for path '/path/to/someFile.ts'] Now I'll look for existing or related test files to understand current testing conventions and dependencies. -[tool_call: read_many_files for paths ['**/*.test.ts', 'src/**/*.spec.ts']] +[tool_call: read_file for path '/path/to/existingTest.test.ts'] (After reviewing existing tests and the file content) [tool_call: write_file for path '/path/to/someFile.test.ts'] I've written the tests. Now I'll run the project's test command to verify them. @@ -873,7 +873,7 @@ To help you check their settings, I can read their contents. Which one would you # Final Reminder -Your core function is efficient and safe assistance. Balance extreme conciseness with the crucial need for clarity, especially regarding safety and potential system modifications. Always prioritize user control and project conventions. Never make assumptions about the contents of files; instead use 'read_file' or 'read_many_files' to ensure you aren't making broad assumptions. Finally, you are an agent - please keep going until the user's query is completely resolved." +Your core function is efficient and safe assistance. Balance extreme conciseness with the crucial need for clarity, especially regarding safety and potential system modifications. Always prioritize user control and project conventions. Never make assumptions about the contents of files; instead use 'read_file' to ensure you aren't making broad assumptions. Finally, you are an agent - please keep going until the user's query is completely resolved." `; exports[`Core System Prompt (prompts.ts) > should include seatbelt-specific instructions when SANDBOX env var is "sandbox-exec" 1`] = ` @@ -945,7 +945,7 @@ I've found some existing telemetry code. Let me mark the first todo as in_progre ## Software Engineering Tasks When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this iterative approach: - **Plan:** After understanding the user's request, create an initial plan based on your existing knowledge and any immediately obvious context. Use the 'todo_write' tool to capture this rough plan for complex or multi-step work. Don't wait for complete understanding - start with what you know. -- **Implement:** Begin implementing the plan while gathering additional context as needed. Use 'grep_search', 'glob', 'read_file', and 'read_many_files' tools strategically when you encounter specific unknowns during implementation. Use the available tools (e.g., 'edit', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates'). +- **Implement:** Begin implementing the plan while gathering additional context as needed. Use 'grep_search', 'glob', and 'read_file' tools strategically when you encounter specific unknowns during implementation. Use the available tools (e.g., 'edit', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates'). - **Adapt:** As you discover new information or encounter obstacles, update your plan and todos accordingly. Mark todos as in_progress when starting and completed when finishing each task. Add new todos if the scope expands. Refine your approach based on what you learn. - **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands. - **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. If unsure about these commands, you can ask the user if they'd like you to run them and if so how to. @@ -1066,7 +1066,7 @@ model: Okay, I can write those tests. First, I'll read someFile.ts to understand its functionality. [tool_call: read_file for path '/path/to/someFile.ts'] Now I'll look for existing or related test files to understand current testing conventions and dependencies. -[tool_call: read_many_files for paths ['**/*.test.ts', 'src/**/*.spec.ts']] +[tool_call: read_file for path '/path/to/existingTest.test.ts'] (After reviewing existing tests and the file content) [tool_call: write_file for path '/path/to/someFile.test.ts'] I've written the tests. Now I'll run the project's test command to verify them. @@ -1087,7 +1087,7 @@ To help you check their settings, I can read their contents. Which one would you # Final Reminder -Your core function is efficient and safe assistance. Balance extreme conciseness with the crucial need for clarity, especially regarding safety and potential system modifications. Always prioritize user control and project conventions. Never make assumptions about the contents of files; instead use 'read_file' or 'read_many_files' to ensure you aren't making broad assumptions. Finally, you are an agent - please keep going until the user's query is completely resolved." +Your core function is efficient and safe assistance. Balance extreme conciseness with the crucial need for clarity, especially regarding safety and potential system modifications. Always prioritize user control and project conventions. Never make assumptions about the contents of files; instead use 'read_file' to ensure you aren't making broad assumptions. Finally, you are an agent - please keep going until the user's query is completely resolved." `; exports[`Core System Prompt (prompts.ts) > should not include git instructions when not in a git repo 1`] = ` @@ -1159,7 +1159,7 @@ I've found some existing telemetry code. Let me mark the first todo as in_progre ## Software Engineering Tasks When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this iterative approach: - **Plan:** After understanding the user's request, create an initial plan based on your existing knowledge and any immediately obvious context. Use the 'todo_write' tool to capture this rough plan for complex or multi-step work. Don't wait for complete understanding - start with what you know. -- **Implement:** Begin implementing the plan while gathering additional context as needed. Use 'grep_search', 'glob', 'read_file', and 'read_many_files' tools strategically when you encounter specific unknowns during implementation. Use the available tools (e.g., 'edit', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates'). +- **Implement:** Begin implementing the plan while gathering additional context as needed. Use 'grep_search', 'glob', and 'read_file' tools strategically when you encounter specific unknowns during implementation. Use the available tools (e.g., 'edit', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates'). - **Adapt:** As you discover new information or encounter obstacles, update your plan and todos accordingly. Mark todos as in_progress when starting and completed when finishing each task. Add new todos if the scope expands. Refine your approach based on what you learn. - **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands. - **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. If unsure about these commands, you can ask the user if they'd like you to run them and if so how to. @@ -1280,7 +1280,7 @@ model: Okay, I can write those tests. First, I'll read someFile.ts to understand its functionality. [tool_call: read_file for path '/path/to/someFile.ts'] Now I'll look for existing or related test files to understand current testing conventions and dependencies. -[tool_call: read_many_files for paths ['**/*.test.ts', 'src/**/*.spec.ts']] +[tool_call: read_file for path '/path/to/existingTest.test.ts'] (After reviewing existing tests and the file content) [tool_call: write_file for path '/path/to/someFile.test.ts'] I've written the tests. Now I'll run the project's test command to verify them. @@ -1301,7 +1301,7 @@ To help you check their settings, I can read their contents. Which one would you # Final Reminder -Your core function is efficient and safe assistance. Balance extreme conciseness with the crucial need for clarity, especially regarding safety and potential system modifications. Always prioritize user control and project conventions. Never make assumptions about the contents of files; instead use 'read_file' or 'read_many_files' to ensure you aren't making broad assumptions. Finally, you are an agent - please keep going until the user's query is completely resolved." +Your core function is efficient and safe assistance. Balance extreme conciseness with the crucial need for clarity, especially regarding safety and potential system modifications. Always prioritize user control and project conventions. Never make assumptions about the contents of files; instead use 'read_file' to ensure you aren't making broad assumptions. Finally, you are an agent - please keep going until the user's query is completely resolved." `; exports[`Core System Prompt (prompts.ts) > should return the base prompt when no userMemory is provided 1`] = ` @@ -1373,7 +1373,7 @@ I've found some existing telemetry code. Let me mark the first todo as in_progre ## Software Engineering Tasks When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this iterative approach: - **Plan:** After understanding the user's request, create an initial plan based on your existing knowledge and any immediately obvious context. Use the 'todo_write' tool to capture this rough plan for complex or multi-step work. Don't wait for complete understanding - start with what you know. -- **Implement:** Begin implementing the plan while gathering additional context as needed. Use 'grep_search', 'glob', 'read_file', and 'read_many_files' tools strategically when you encounter specific unknowns during implementation. Use the available tools (e.g., 'edit', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates'). +- **Implement:** Begin implementing the plan while gathering additional context as needed. Use 'grep_search', 'glob', and 'read_file' tools strategically when you encounter specific unknowns during implementation. Use the available tools (e.g., 'edit', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates'). - **Adapt:** As you discover new information or encounter obstacles, update your plan and todos accordingly. Mark todos as in_progress when starting and completed when finishing each task. Add new todos if the scope expands. Refine your approach based on what you learn. - **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands. - **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. If unsure about these commands, you can ask the user if they'd like you to run them and if so how to. @@ -1494,7 +1494,7 @@ model: Okay, I can write those tests. First, I'll read someFile.ts to understand its functionality. [tool_call: read_file for path '/path/to/someFile.ts'] Now I'll look for existing or related test files to understand current testing conventions and dependencies. -[tool_call: read_many_files for paths ['**/*.test.ts', 'src/**/*.spec.ts']] +[tool_call: read_file for path '/path/to/existingTest.test.ts'] (After reviewing existing tests and the file content) [tool_call: write_file for path '/path/to/someFile.test.ts'] I've written the tests. Now I'll run the project's test command to verify them. @@ -1515,7 +1515,7 @@ To help you check their settings, I can read their contents. Which one would you # Final Reminder -Your core function is efficient and safe assistance. Balance extreme conciseness with the crucial need for clarity, especially regarding safety and potential system modifications. Always prioritize user control and project conventions. Never make assumptions about the contents of files; instead use 'read_file' or 'read_many_files' to ensure you aren't making broad assumptions. Finally, you are an agent - please keep going until the user's query is completely resolved." +Your core function is efficient and safe assistance. Balance extreme conciseness with the crucial need for clarity, especially regarding safety and potential system modifications. Always prioritize user control and project conventions. Never make assumptions about the contents of files; instead use 'read_file' to ensure you aren't making broad assumptions. Finally, you are an agent - please keep going until the user's query is completely resolved." `; exports[`Core System Prompt (prompts.ts) > should return the base prompt when userMemory is empty string 1`] = ` @@ -1587,7 +1587,7 @@ I've found some existing telemetry code. Let me mark the first todo as in_progre ## Software Engineering Tasks When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this iterative approach: - **Plan:** After understanding the user's request, create an initial plan based on your existing knowledge and any immediately obvious context. Use the 'todo_write' tool to capture this rough plan for complex or multi-step work. Don't wait for complete understanding - start with what you know. -- **Implement:** Begin implementing the plan while gathering additional context as needed. Use 'grep_search', 'glob', 'read_file', and 'read_many_files' tools strategically when you encounter specific unknowns during implementation. Use the available tools (e.g., 'edit', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates'). +- **Implement:** Begin implementing the plan while gathering additional context as needed. Use 'grep_search', 'glob', and 'read_file' tools strategically when you encounter specific unknowns during implementation. Use the available tools (e.g., 'edit', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates'). - **Adapt:** As you discover new information or encounter obstacles, update your plan and todos accordingly. Mark todos as in_progress when starting and completed when finishing each task. Add new todos if the scope expands. Refine your approach based on what you learn. - **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands. - **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. If unsure about these commands, you can ask the user if they'd like you to run them and if so how to. @@ -1708,7 +1708,7 @@ model: Okay, I can write those tests. First, I'll read someFile.ts to understand its functionality. [tool_call: read_file for path '/path/to/someFile.ts'] Now I'll look for existing or related test files to understand current testing conventions and dependencies. -[tool_call: read_many_files for paths ['**/*.test.ts', 'src/**/*.spec.ts']] +[tool_call: read_file for path '/path/to/existingTest.test.ts'] (After reviewing existing tests and the file content) [tool_call: write_file for path '/path/to/someFile.test.ts'] I've written the tests. Now I'll run the project's test command to verify them. @@ -1729,7 +1729,7 @@ To help you check their settings, I can read their contents. Which one would you # Final Reminder -Your core function is efficient and safe assistance. Balance extreme conciseness with the crucial need for clarity, especially regarding safety and potential system modifications. Always prioritize user control and project conventions. Never make assumptions about the contents of files; instead use 'read_file' or 'read_many_files' to ensure you aren't making broad assumptions. Finally, you are an agent - please keep going until the user's query is completely resolved." +Your core function is efficient and safe assistance. Balance extreme conciseness with the crucial need for clarity, especially regarding safety and potential system modifications. Always prioritize user control and project conventions. Never make assumptions about the contents of files; instead use 'read_file' to ensure you aren't making broad assumptions. Finally, you are an agent - please keep going until the user's query is completely resolved." `; exports[`Core System Prompt (prompts.ts) > should return the base prompt when userMemory is whitespace only 1`] = ` @@ -1801,7 +1801,7 @@ I've found some existing telemetry code. Let me mark the first todo as in_progre ## Software Engineering Tasks When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this iterative approach: - **Plan:** After understanding the user's request, create an initial plan based on your existing knowledge and any immediately obvious context. Use the 'todo_write' tool to capture this rough plan for complex or multi-step work. Don't wait for complete understanding - start with what you know. -- **Implement:** Begin implementing the plan while gathering additional context as needed. Use 'grep_search', 'glob', 'read_file', and 'read_many_files' tools strategically when you encounter specific unknowns during implementation. Use the available tools (e.g., 'edit', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates'). +- **Implement:** Begin implementing the plan while gathering additional context as needed. Use 'grep_search', 'glob', and 'read_file' tools strategically when you encounter specific unknowns during implementation. Use the available tools (e.g., 'edit', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates'). - **Adapt:** As you discover new information or encounter obstacles, update your plan and todos accordingly. Mark todos as in_progress when starting and completed when finishing each task. Add new todos if the scope expands. Refine your approach based on what you learn. - **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands. - **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. If unsure about these commands, you can ask the user if they'd like you to run them and if so how to. @@ -1922,7 +1922,7 @@ model: Okay, I can write those tests. First, I'll read someFile.ts to understand its functionality. [tool_call: read_file for path '/path/to/someFile.ts'] Now I'll look for existing or related test files to understand current testing conventions and dependencies. -[tool_call: read_many_files for paths ['**/*.test.ts', 'src/**/*.spec.ts']] +[tool_call: read_file for path '/path/to/existingTest.test.ts'] (After reviewing existing tests and the file content) [tool_call: write_file for path '/path/to/someFile.test.ts'] I've written the tests. Now I'll run the project's test command to verify them. @@ -1943,7 +1943,7 @@ To help you check their settings, I can read their contents. Which one would you # Final Reminder -Your core function is efficient and safe assistance. Balance extreme conciseness with the crucial need for clarity, especially regarding safety and potential system modifications. Always prioritize user control and project conventions. Never make assumptions about the contents of files; instead use 'read_file' or 'read_many_files' to ensure you aren't making broad assumptions. Finally, you are an agent - please keep going until the user's query is completely resolved." +Your core function is efficient and safe assistance. Balance extreme conciseness with the crucial need for clarity, especially regarding safety and potential system modifications. Always prioritize user control and project conventions. Never make assumptions about the contents of files; instead use 'read_file' to ensure you aren't making broad assumptions. Finally, you are an agent - please keep going until the user's query is completely resolved." `; exports[`Model-specific tool call formats > should preserve model-specific formats with sandbox environment 1`] = ` @@ -2015,7 +2015,7 @@ I've found some existing telemetry code. Let me mark the first todo as in_progre ## Software Engineering Tasks When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this iterative approach: - **Plan:** After understanding the user's request, create an initial plan based on your existing knowledge and any immediately obvious context. Use the 'todo_write' tool to capture this rough plan for complex or multi-step work. Don't wait for complete understanding - start with what you know. -- **Implement:** Begin implementing the plan while gathering additional context as needed. Use 'grep_search', 'glob', 'read_file', and 'read_many_files' tools strategically when you encounter specific unknowns during implementation. Use the available tools (e.g., 'edit', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates'). +- **Implement:** Begin implementing the plan while gathering additional context as needed. Use 'grep_search', 'glob', and 'read_file' tools strategically when you encounter specific unknowns during implementation. Use the available tools (e.g., 'edit', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates'). - **Adapt:** As you discover new information or encounter obstacles, update your plan and todos accordingly. Mark todos as in_progress when starting and completed when finishing each task. Add new todos if the scope expands. Refine your approach based on what you learn. - **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands. - **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. If unsure about these commands, you can ask the user if they'd like you to run them and if so how to. @@ -2152,7 +2152,7 @@ Okay, I can write those tests. First, I'll read someFile.ts to understand its fu Now I'll look for existing or related test files to understand current testing conventions and dependencies. -{"name": "read_many_files", "arguments": {"paths": ["**/*.test.ts", "src/**/*.spec.ts"]}} +{"name": "read_file", "arguments": {"path": "/path/to/existingTest.test.ts"}} (After reviewing existing tests and the file content) @@ -2180,7 +2180,7 @@ To help you check their settings, I can read their contents. Which one would you # Final Reminder -Your core function is efficient and safe assistance. Balance extreme conciseness with the crucial need for clarity, especially regarding safety and potential system modifications. Always prioritize user control and project conventions. Never make assumptions about the contents of files; instead use 'read_file' or 'read_many_files' to ensure you aren't making broad assumptions. Finally, you are an agent - please keep going until the user's query is completely resolved." +Your core function is efficient and safe assistance. Balance extreme conciseness with the crucial need for clarity, especially regarding safety and potential system modifications. Always prioritize user control and project conventions. Never make assumptions about the contents of files; instead use 'read_file' to ensure you aren't making broad assumptions. Finally, you are an agent - please keep going until the user's query is completely resolved." `; exports[`Model-specific tool call formats > should preserve model-specific formats with user memory 1`] = ` @@ -2252,7 +2252,7 @@ I've found some existing telemetry code. Let me mark the first todo as in_progre ## Software Engineering Tasks When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this iterative approach: - **Plan:** After understanding the user's request, create an initial plan based on your existing knowledge and any immediately obvious context. Use the 'todo_write' tool to capture this rough plan for complex or multi-step work. Don't wait for complete understanding - start with what you know. -- **Implement:** Begin implementing the plan while gathering additional context as needed. Use 'grep_search', 'glob', 'read_file', and 'read_many_files' tools strategically when you encounter specific unknowns during implementation. Use the available tools (e.g., 'edit', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates'). +- **Implement:** Begin implementing the plan while gathering additional context as needed. Use 'grep_search', 'glob', and 'read_file' tools strategically when you encounter specific unknowns during implementation. Use the available tools (e.g., 'edit', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates'). - **Adapt:** As you discover new information or encounter obstacles, update your plan and todos accordingly. Mark todos as in_progress when starting and completed when finishing each task. Add new todos if the scope expands. Refine your approach based on what you learn. - **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands. - **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. If unsure about these commands, you can ask the user if they'd like you to run them and if so how to. @@ -2429,9 +2429,9 @@ Okay, I can write those tests. First, I'll read someFile.ts to understand its fu Now I'll look for existing or related test files to understand current testing conventions and dependencies. - - -['**/*.test.ts', 'src/**/*.spec.ts'] + + +/path/to/existingTest.test.ts @@ -2473,7 +2473,7 @@ To help you check their settings, I can read their contents. Which one would you # Final Reminder -Your core function is efficient and safe assistance. Balance extreme conciseness with the crucial need for clarity, especially regarding safety and potential system modifications. Always prioritize user control and project conventions. Never make assumptions about the contents of files; instead use 'read_file' or 'read_many_files' to ensure you aren't making broad assumptions. Finally, you are an agent - please keep going until the user's query is completely resolved. +Your core function is efficient and safe assistance. Balance extreme conciseness with the crucial need for clarity, especially regarding safety and potential system modifications. Always prioritize user control and project conventions. Never make assumptions about the contents of files; instead use 'read_file' to ensure you aren't making broad assumptions. Finally, you are an agent - please keep going until the user's query is completely resolved. --- @@ -2549,7 +2549,7 @@ I've found some existing telemetry code. Let me mark the first todo as in_progre ## Software Engineering Tasks When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this iterative approach: - **Plan:** After understanding the user's request, create an initial plan based on your existing knowledge and any immediately obvious context. Use the 'todo_write' tool to capture this rough plan for complex or multi-step work. Don't wait for complete understanding - start with what you know. -- **Implement:** Begin implementing the plan while gathering additional context as needed. Use 'grep_search', 'glob', 'read_file', and 'read_many_files' tools strategically when you encounter specific unknowns during implementation. Use the available tools (e.g., 'edit', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates'). +- **Implement:** Begin implementing the plan while gathering additional context as needed. Use 'grep_search', 'glob', and 'read_file' tools strategically when you encounter specific unknowns during implementation. Use the available tools (e.g., 'edit', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates'). - **Adapt:** As you discover new information or encounter obstacles, update your plan and todos accordingly. Mark todos as in_progress when starting and completed when finishing each task. Add new todos if the scope expands. Refine your approach based on what you learn. - **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands. - **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. If unsure about these commands, you can ask the user if they'd like you to run them and if so how to. @@ -2686,7 +2686,7 @@ Okay, I can write those tests. First, I'll read someFile.ts to understand its fu Now I'll look for existing or related test files to understand current testing conventions and dependencies. -{"name": "read_many_files", "arguments": {"paths": ["**/*.test.ts", "src/**/*.spec.ts"]}} +{"name": "read_file", "arguments": {"path": "/path/to/existingTest.test.ts"}} (After reviewing existing tests and the file content) @@ -2714,7 +2714,7 @@ To help you check their settings, I can read their contents. Which one would you # Final Reminder -Your core function is efficient and safe assistance. Balance extreme conciseness with the crucial need for clarity, especially regarding safety and potential system modifications. Always prioritize user control and project conventions. Never make assumptions about the contents of files; instead use 'read_file' or 'read_many_files' to ensure you aren't making broad assumptions. Finally, you are an agent - please keep going until the user's query is completely resolved." +Your core function is efficient and safe assistance. Balance extreme conciseness with the crucial need for clarity, especially regarding safety and potential system modifications. Always prioritize user control and project conventions. Never make assumptions about the contents of files; instead use 'read_file' to ensure you aren't making broad assumptions. Finally, you are an agent - please keep going until the user's query is completely resolved." `; exports[`Model-specific tool call formats > should use XML format for qwen3-coder model 1`] = ` @@ -2786,7 +2786,7 @@ I've found some existing telemetry code. Let me mark the first todo as in_progre ## Software Engineering Tasks When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this iterative approach: - **Plan:** After understanding the user's request, create an initial plan based on your existing knowledge and any immediately obvious context. Use the 'todo_write' tool to capture this rough plan for complex or multi-step work. Don't wait for complete understanding - start with what you know. -- **Implement:** Begin implementing the plan while gathering additional context as needed. Use 'grep_search', 'glob', 'read_file', and 'read_many_files' tools strategically when you encounter specific unknowns during implementation. Use the available tools (e.g., 'edit', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates'). +- **Implement:** Begin implementing the plan while gathering additional context as needed. Use 'grep_search', 'glob', and 'read_file' tools strategically when you encounter specific unknowns during implementation. Use the available tools (e.g., 'edit', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates'). - **Adapt:** As you discover new information or encounter obstacles, update your plan and todos accordingly. Mark todos as in_progress when starting and completed when finishing each task. Add new todos if the scope expands. Refine your approach based on what you learn. - **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands. - **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. If unsure about these commands, you can ask the user if they'd like you to run them and if so how to. @@ -2963,9 +2963,9 @@ Okay, I can write those tests. First, I'll read someFile.ts to understand its fu Now I'll look for existing or related test files to understand current testing conventions and dependencies. - - -['**/*.test.ts', 'src/**/*.spec.ts'] + + +/path/to/existingTest.test.ts @@ -3007,7 +3007,7 @@ To help you check their settings, I can read their contents. Which one would you # Final Reminder -Your core function is efficient and safe assistance. Balance extreme conciseness with the crucial need for clarity, especially regarding safety and potential system modifications. Always prioritize user control and project conventions. Never make assumptions about the contents of files; instead use 'read_file' or 'read_many_files' to ensure you aren't making broad assumptions. Finally, you are an agent - please keep going until the user's query is completely resolved." +Your core function is efficient and safe assistance. Balance extreme conciseness with the crucial need for clarity, especially regarding safety and potential system modifications. Always prioritize user control and project conventions. Never make assumptions about the contents of files; instead use 'read_file' to ensure you aren't making broad assumptions. Finally, you are an agent - please keep going until the user's query is completely resolved." `; exports[`Model-specific tool call formats > should use bracket format for generic models 1`] = ` @@ -3079,7 +3079,7 @@ I've found some existing telemetry code. Let me mark the first todo as in_progre ## Software Engineering Tasks When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this iterative approach: - **Plan:** After understanding the user's request, create an initial plan based on your existing knowledge and any immediately obvious context. Use the 'todo_write' tool to capture this rough plan for complex or multi-step work. Don't wait for complete understanding - start with what you know. -- **Implement:** Begin implementing the plan while gathering additional context as needed. Use 'grep_search', 'glob', 'read_file', and 'read_many_files' tools strategically when you encounter specific unknowns during implementation. Use the available tools (e.g., 'edit', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates'). +- **Implement:** Begin implementing the plan while gathering additional context as needed. Use 'grep_search', 'glob', and 'read_file' tools strategically when you encounter specific unknowns during implementation. Use the available tools (e.g., 'edit', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates'). - **Adapt:** As you discover new information or encounter obstacles, update your plan and todos accordingly. Mark todos as in_progress when starting and completed when finishing each task. Add new todos if the scope expands. Refine your approach based on what you learn. - **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands. - **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. If unsure about these commands, you can ask the user if they'd like you to run them and if so how to. @@ -3200,7 +3200,7 @@ model: Okay, I can write those tests. First, I'll read someFile.ts to understand its functionality. [tool_call: read_file for path '/path/to/someFile.ts'] Now I'll look for existing or related test files to understand current testing conventions and dependencies. -[tool_call: read_many_files for paths ['**/*.test.ts', 'src/**/*.spec.ts']] +[tool_call: read_file for path '/path/to/existingTest.test.ts'] (After reviewing existing tests and the file content) [tool_call: write_file for path '/path/to/someFile.test.ts'] I've written the tests. Now I'll run the project's test command to verify them. @@ -3221,7 +3221,7 @@ To help you check their settings, I can read their contents. Which one would you # Final Reminder -Your core function is efficient and safe assistance. Balance extreme conciseness with the crucial need for clarity, especially regarding safety and potential system modifications. Always prioritize user control and project conventions. Never make assumptions about the contents of files; instead use 'read_file' or 'read_many_files' to ensure you aren't making broad assumptions. Finally, you are an agent - please keep going until the user's query is completely resolved." +Your core function is efficient and safe assistance. Balance extreme conciseness with the crucial need for clarity, especially regarding safety and potential system modifications. Always prioritize user control and project conventions. Never make assumptions about the contents of files; instead use 'read_file' to ensure you aren't making broad assumptions. Finally, you are an agent - please keep going until the user's query is completely resolved." `; exports[`Model-specific tool call formats > should use bracket format when no model is specified 1`] = ` @@ -3293,7 +3293,7 @@ I've found some existing telemetry code. Let me mark the first todo as in_progre ## Software Engineering Tasks When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this iterative approach: - **Plan:** After understanding the user's request, create an initial plan based on your existing knowledge and any immediately obvious context. Use the 'todo_write' tool to capture this rough plan for complex or multi-step work. Don't wait for complete understanding - start with what you know. -- **Implement:** Begin implementing the plan while gathering additional context as needed. Use 'grep_search', 'glob', 'read_file', and 'read_many_files' tools strategically when you encounter specific unknowns during implementation. Use the available tools (e.g., 'edit', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates'). +- **Implement:** Begin implementing the plan while gathering additional context as needed. Use 'grep_search', 'glob', and 'read_file' tools strategically when you encounter specific unknowns during implementation. Use the available tools (e.g., 'edit', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates'). - **Adapt:** As you discover new information or encounter obstacles, update your plan and todos accordingly. Mark todos as in_progress when starting and completed when finishing each task. Add new todos if the scope expands. Refine your approach based on what you learn. - **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands. - **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. If unsure about these commands, you can ask the user if they'd like you to run them and if so how to. @@ -3414,7 +3414,7 @@ model: Okay, I can write those tests. First, I'll read someFile.ts to understand its functionality. [tool_call: read_file for path '/path/to/someFile.ts'] Now I'll look for existing or related test files to understand current testing conventions and dependencies. -[tool_call: read_many_files for paths ['**/*.test.ts', 'src/**/*.spec.ts']] +[tool_call: read_file for path '/path/to/existingTest.test.ts'] (After reviewing existing tests and the file content) [tool_call: write_file for path '/path/to/someFile.test.ts'] I've written the tests. Now I'll run the project's test command to verify them. @@ -3435,5 +3435,5 @@ To help you check their settings, I can read their contents. Which one would you # Final Reminder -Your core function is efficient and safe assistance. Balance extreme conciseness with the crucial need for clarity, especially regarding safety and potential system modifications. Always prioritize user control and project conventions. Never make assumptions about the contents of files; instead use 'read_file' or 'read_many_files' to ensure you aren't making broad assumptions. Finally, you are an agent - please keep going until the user's query is completely resolved." +Your core function is efficient and safe assistance. Balance extreme conciseness with the crucial need for clarity, especially regarding safety and potential system modifications. Always prioritize user control and project conventions. Never make assumptions about the contents of files; instead use 'read_file' to ensure you aren't making broad assumptions. Finally, you are an agent - please keep going until the user's query is completely resolved." `; diff --git a/packages/core/src/core/prompts.ts b/packages/core/src/core/prompts.ts index 8d3ff4683..b0ac5fac4 100644 --- a/packages/core/src/core/prompts.ts +++ b/packages/core/src/core/prompts.ts @@ -203,7 +203,7 @@ I've found some existing telemetry code. Let me mark the first todo as in_progre ## Software Engineering Tasks When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this iterative approach: - **Plan:** After understanding the user's request, create an initial plan based on your existing knowledge and any immediately obvious context. Use the '${ToolNames.TODO_WRITE}' tool to capture this rough plan for complex or multi-step work. Don't wait for complete understanding - start with what you know. -- **Implement:** Begin implementing the plan while gathering additional context as needed. Use '${ToolNames.GREP}', '${ToolNames.GLOB}', '${ToolNames.READ_FILE}', and '${ToolNames.READ_MANY_FILES}' tools strategically when you encounter specific unknowns during implementation. Use the available tools (e.g., '${ToolNames.EDIT}', '${ToolNames.WRITE_FILE}' '${ToolNames.SHELL}' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates'). +- **Implement:** Begin implementing the plan while gathering additional context as needed. Use '${ToolNames.GREP}', '${ToolNames.GLOB}', and '${ToolNames.READ_FILE}' tools strategically when you encounter specific unknowns during implementation. Use the available tools (e.g., '${ToolNames.EDIT}', '${ToolNames.WRITE_FILE}' '${ToolNames.SHELL}' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates'). - **Adapt:** As you discover new information or encounter obstacles, update your plan and todos accordingly. Mark todos as in_progress when starting and completed when finishing each task. Add new todos if the scope expands. Refine your approach based on what you learn. - **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands. - **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. If unsure about these commands, you can ask the user if they'd like you to run them and if so how to. @@ -311,7 +311,7 @@ ${(function () { ${getToolCallExamples(model || '')} # Final Reminder -Your core function is efficient and safe assistance. Balance extreme conciseness with the crucial need for clarity, especially regarding safety and potential system modifications. Always prioritize user control and project conventions. Never make assumptions about the contents of files; instead use '${ToolNames.READ_FILE}' or '${ToolNames.READ_MANY_FILES}' to ensure you aren't making broad assumptions. Finally, you are an agent - please keep going until the user's query is completely resolved. +Your core function is efficient and safe assistance. Balance extreme conciseness with the crucial need for clarity, especially regarding safety and potential system modifications. Always prioritize user control and project conventions. Never make assumptions about the contents of files; instead use '${ToolNames.READ_FILE}' to ensure you aren't making broad assumptions. Finally, you are an agent - please keep going until the user's query is completely resolved. `.trim(); // if QWEN_WRITE_SYSTEM_MD is set (and not 0|false), write base system prompt to file @@ -488,7 +488,7 @@ model: Okay, I can write those tests. First, I'll read someFile.ts to understand its functionality. [tool_call: ${ToolNames.READ_FILE} for path '/path/to/someFile.ts'] Now I'll look for existing or related test files to understand current testing conventions and dependencies. -[tool_call: ${ToolNames.READ_MANY_FILES} for paths ['**/*.test.ts', 'src/**/*.spec.ts']] +[tool_call: ${ToolNames.READ_FILE} for path '/path/to/existingTest.test.ts'] (After reviewing existing tests and the file content) [tool_call: ${ToolNames.WRITE_FILE} for path '/path/to/someFile.test.ts'] I've written the tests. Now I'll run the project's test command to verify them. @@ -620,9 +620,9 @@ Okay, I can write those tests. First, I'll read someFile.ts to understand its fu Now I'll look for existing or related test files to understand current testing conventions and dependencies. - - -['**/*.test.ts', 'src/**/*.spec.ts'] + + +/path/to/existingTest.test.ts @@ -734,7 +734,7 @@ Okay, I can write those tests. First, I'll read someFile.ts to understand its fu Now I'll look for existing or related test files to understand current testing conventions and dependencies. -{"name": "${ToolNames.READ_MANY_FILES}", "arguments": {"paths": ["**/*.test.ts", "src/**/*.spec.ts"]}} +{"name": "${ToolNames.READ_FILE}", "arguments": {"path": "/path/to/existingTest.test.ts"}} (After reviewing existing tests and the file content) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a9c091a08..0661ce6a7 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -78,6 +78,7 @@ export * from './utils/promptIdContext.js'; export * from './utils/thoughtUtils.js'; export * from './utils/toml-to-markdown-converter.js'; export * from './utils/yaml-parser.js'; +export * from './utils/readManyFiles.js'; // Config resolution utilities export * from './utils/configResolver.js'; @@ -104,6 +105,7 @@ export * from './services/shellExecutionService.js'; export * from './tools/tools.js'; export * from './tools/tool-error.js'; export * from './tools/tool-registry.js'; +export * from './tools/tool-names.js'; // Export subagents (Phase 1) export * from './subagents/index.js'; @@ -129,7 +131,6 @@ export * from './tools/web-fetch.js'; export * from './tools/memoryTool.js'; export * from './tools/shell.js'; export * from './tools/web-search/index.js'; -export * from './tools/read-many-files.js'; export * from './tools/mcp-client.js'; export * from './tools/mcp-client-manager.js'; export * from './tools/mcp-tool.js'; diff --git a/packages/core/src/services/chatRecordingService.test.ts b/packages/core/src/services/chatRecordingService.test.ts index 050b9381f..fff565198 100644 --- a/packages/core/src/services/chatRecordingService.test.ts +++ b/packages/core/src/services/chatRecordingService.test.ts @@ -13,6 +13,7 @@ import type { Config } from '../config/config.js'; import { ChatRecordingService, type ChatRecord, + type AtCommandRecordPayload, } from './chatRecordingService.js'; import * as jsonl from '../utils/jsonl-utils.js'; import type { Part } from '@google/genai'; @@ -131,6 +132,33 @@ describe('ChatRecordingService', () => { }); }); + describe('recordAtCommand', () => { + it('should record @-command metadata as a system payload', () => { + const userParts: Part[] = [{ text: 'Hello, world!' }]; + const payload: AtCommandRecordPayload = { + filesRead: ['foo.txt'], + status: 'success', + message: 'Success', + userText: '@foo.txt', + }; + + chatRecordingService.recordUserMessage(userParts); + chatRecordingService.recordAtCommand(payload); + + expect(jsonl.writeLineSync).toHaveBeenCalledTimes(2); + const userRecord = vi.mocked(jsonl.writeLineSync).mock + .calls[0][1] as ChatRecord; + const systemRecord = vi.mocked(jsonl.writeLineSync).mock + .calls[1][1] as ChatRecord; + + expect(userRecord.type).toBe('user'); + expect(systemRecord.type).toBe('system'); + expect(systemRecord.subtype).toBe('at_command'); + expect(systemRecord.systemPayload).toEqual(payload); + expect(systemRecord.parentUuid).toBe(userRecord.uuid); + }); + }); + describe('recordAssistantTurn', () => { it('should record assistant turn with content only', () => { const parts: Part[] = [{ text: 'Hello!' }]; diff --git a/packages/core/src/services/chatRecordingService.ts b/packages/core/src/services/chatRecordingService.ts index b56302126..57306dac8 100644 --- a/packages/core/src/services/chatRecordingService.ts +++ b/packages/core/src/services/chatRecordingService.ts @@ -50,7 +50,11 @@ export interface ChatRecord { */ type: 'user' | 'assistant' | 'tool_result' | 'system'; /** Optional system subtype for distinguishing system behaviors */ - subtype?: 'chat_compression' | 'slash_command' | 'ui_telemetry'; + subtype?: + | 'chat_compression' + | 'slash_command' + | 'ui_telemetry' + | 'at_command'; /** Working directory at time of message */ cwd: string; /** CLI version for compatibility tracking */ @@ -87,7 +91,8 @@ export interface ChatRecord { systemPayload?: | ChatCompressionRecordPayload | SlashCommandRecordPayload - | UiTelemetryRecordPayload; + | UiTelemetryRecordPayload + | AtCommandRecordPayload; } /** @@ -117,6 +122,20 @@ export interface SlashCommandRecordPayload { outputHistoryItems?: Array>; } +/** + * Stored payload for @-command replay. + */ +export interface AtCommandRecordPayload { + /** Files that were read for this @-command. */ + filesRead: string[]; + /** Status for UI reconstruction. */ + status: 'success' | 'error'; + /** Optional result message for UI reconstruction. */ + message?: string; + /** Raw user-entered @-command query (optional for legacy records). */ + userText?: string; +} + /** * Stored payload for UI telemetry replay. */ @@ -405,4 +424,22 @@ export class ChatRecordingService { console.error('Error saving ui telemetry record:', error); } } + + /** + * Records @-command metadata as a system record for UI reconstruction. + */ + recordAtCommand(payload: AtCommandRecordPayload): void { + try { + const record: ChatRecord = { + ...this.createBaseRecord('system'), + type: 'system', + subtype: 'at_command', + systemPayload: payload, + }; + + this.appendRecord(record); + } catch (error) { + console.error('Error saving @-command record:', error); + } + } } diff --git a/packages/core/src/tools/read-many-files.test.ts b/packages/core/src/tools/read-many-files.test.ts deleted file mode 100644 index f755abecc..000000000 --- a/packages/core/src/tools/read-many-files.test.ts +++ /dev/null @@ -1,759 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; -import type { Mock } from 'vitest'; -import { mockControl } from '../__mocks__/fs/promises.js'; -import { ReadManyFilesTool } from './read-many-files.js'; -import { FileDiscoveryService } from '../services/fileDiscoveryService.js'; -import path from 'node:path'; -import fs from 'node:fs'; // Actual fs for setup -import os from 'node:os'; -import type { Config } from '../config/config.js'; -import { WorkspaceContext } from '../utils/workspaceContext.js'; -import { StandardFileSystemService } from '../services/fileSystemService.js'; -import { ToolErrorType } from './tool-error.js'; -import { - COMMON_IGNORE_PATTERNS, - DEFAULT_FILE_EXCLUDES, -} from '../utils/ignorePatterns.js'; -import * as glob from 'glob'; - -vi.mock('glob', { spy: true }); - -vi.mock('mime', () => { - const getType = (filename: string) => { - if (filename.endsWith('.ts') || filename.endsWith('.js')) { - return 'text/plain'; - } - if (filename.endsWith('.png')) { - return 'image/png'; - } - if (filename.endsWith('.pdf')) { - return 'application/pdf'; - } - if (filename.endsWith('.mp3') || filename.endsWith('.wav')) { - return 'audio/mpeg'; - } - if (filename.endsWith('.mp4') || filename.endsWith('.mov')) { - return 'video/mp4'; - } - return false; - }; - return { - default: { - getType, - }, - getType, - }; -}); - -vi.mock('../telemetry/loggers.js', () => ({ - logFileOperation: vi.fn(), -})); - -describe('ReadManyFilesTool', () => { - let tool: ReadManyFilesTool; - let tempRootDir: string; - let tempDirOutsideRoot: string; - let mockReadFileFn: Mock; - - beforeEach(async () => { - tempRootDir = fs.realpathSync( - fs.mkdtempSync(path.join(os.tmpdir(), 'read-many-files-root-')), - ); - tempDirOutsideRoot = fs.realpathSync( - fs.mkdtempSync(path.join(os.tmpdir(), 'read-many-files-external-')), - ); - fs.writeFileSync(path.join(tempRootDir, '.qwenignore'), 'foo.*'); - const fileService = new FileDiscoveryService(tempRootDir); - const mockConfig = { - getFileService: () => fileService, - getFileSystemService: () => new StandardFileSystemService(), - - getFileFilteringOptions: () => ({ - respectGitIgnore: true, - respectQwenIgnore: true, - }), - getTargetDir: () => tempRootDir, - getWorkspaceDirs: () => [tempRootDir], - getWorkspaceContext: () => new WorkspaceContext(tempRootDir), - getFileExclusions: () => ({ - getCoreIgnorePatterns: () => COMMON_IGNORE_PATTERNS, - getDefaultExcludePatterns: () => DEFAULT_FILE_EXCLUDES, - getGlobExcludes: () => COMMON_IGNORE_PATTERNS, - buildExcludePatterns: () => DEFAULT_FILE_EXCLUDES, - getReadManyFilesExcludes: () => DEFAULT_FILE_EXCLUDES, - }), - getTruncateToolOutputThreshold: () => 2500, - getTruncateToolOutputLines: () => 500, - } as Partial as Config; - tool = new ReadManyFilesTool(mockConfig); - - mockReadFileFn = mockControl.mockReadFile; - mockReadFileFn.mockReset(); - - mockReadFileFn.mockImplementation( - async (filePath: fs.PathLike, options?: Record) => { - const fp = - typeof filePath === 'string' - ? filePath - : (filePath as Buffer).toString(); - - if (fs.existsSync(fp)) { - const originalFs = await vi.importActual('fs'); - return originalFs.promises.readFile(fp, options); - } - - if (fp.endsWith('nonexistent-file.txt')) { - const err = new Error( - `ENOENT: no such file or directory, open '${fp}'`, - ); - (err as NodeJS.ErrnoException).code = 'ENOENT'; - throw err; - } - if (fp.endsWith('unreadable.txt')) { - const err = new Error(`EACCES: permission denied, open '${fp}'`); - (err as NodeJS.ErrnoException).code = 'EACCES'; - throw err; - } - if (fp.endsWith('.png')) - return Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); // PNG header - if (fp.endsWith('.pdf')) return Buffer.from('%PDF-1.4...'); // PDF start - if (fp.endsWith('binary.bin')) - return Buffer.from([0x00, 0x01, 0x02, 0x00, 0x03]); - - const err = new Error( - `ENOENT: no such file or directory, open '${fp}' (unmocked path)`, - ); - (err as NodeJS.ErrnoException).code = 'ENOENT'; - throw err; - }, - ); - }); - - afterEach(() => { - if (fs.existsSync(tempRootDir)) { - fs.rmSync(tempRootDir, { recursive: true, force: true }); - } - if (fs.existsSync(tempDirOutsideRoot)) { - fs.rmSync(tempDirOutsideRoot, { recursive: true, force: true }); - } - }); - - describe('build', () => { - it('should return an invocation for valid relative paths within root', () => { - const params = { paths: ['file1.txt', 'subdir/file2.txt'] }; - const invocation = tool.build(params); - expect(invocation).toBeDefined(); - }); - - it('should return an invocation for valid glob patterns within root', () => { - const params = { paths: ['*.txt', 'subdir/**/*.js'] }; - const invocation = tool.build(params); - expect(invocation).toBeDefined(); - }); - - it('should return an invocation for paths trying to escape the root (e.g., ../) as execute handles this', () => { - const params = { paths: ['../outside.txt'] }; - const invocation = tool.build(params); - expect(invocation).toBeDefined(); - }); - - it('should return an invocation for absolute paths as execute handles this', () => { - const params = { paths: [path.join(tempDirOutsideRoot, 'absolute.txt')] }; - const invocation = tool.build(params); - expect(invocation).toBeDefined(); - }); - - it('should throw error if paths array is empty', () => { - const params = { paths: [] }; - expect(() => tool.build(params)).toThrow( - 'params/paths must NOT have fewer than 1 items', - ); - }); - - it('should return an invocation for valid exclude and include patterns', () => { - const params = { - paths: ['src/**/*.ts'], - exclude: ['**/*.test.ts'], - include: ['src/utils/*.ts'], - }; - const invocation = tool.build(params); - expect(invocation).toBeDefined(); - }); - - it('should throw error if paths array contains an empty string', () => { - const params = { paths: ['file1.txt', ''] }; - expect(() => tool.build(params)).toThrow( - 'params/paths/1 must NOT have fewer than 1 characters', - ); - }); - - it('should coerce non-string elements in include array', () => { - const params = { - paths: ['file1.txt'], - include: ['*.ts', 123] as string[], - }; - expect(() => tool.build(params)).toBeDefined(); - }); - - it('should throw error if exclude array contains non-string elements', () => { - const params = { - paths: ['file1.txt'], - exclude: ['*.log', {}] as string[], - }; - expect(() => tool.build(params)).toThrow( - 'params/exclude/1 must be string', - ); - }); - }); - - describe('execute', () => { - const createFile = (filePath: string, content = '') => { - const fullPath = path.join(tempRootDir, filePath); - fs.mkdirSync(path.dirname(fullPath), { recursive: true }); - fs.writeFileSync(fullPath, content); - }; - const createBinaryFile = (filePath: string, data: Uint8Array) => { - const fullPath = path.join(tempRootDir, filePath); - fs.mkdirSync(path.dirname(fullPath), { recursive: true }); - fs.writeFileSync(fullPath, data); - }; - - it('should read a single specified file', async () => { - createFile('file1.txt', 'Content of file1'); - const params = { paths: ['file1.txt'] }; - const invocation = tool.build(params); - const result = await invocation.execute(new AbortController().signal); - const expectedPath = path.join(tempRootDir, 'file1.txt'); - expect(result.llmContent).toEqual([ - `--- ${expectedPath} ---\n\nContent of file1\n\n`, - `\n--- End of content ---`, - ]); - expect(result.returnDisplay).toContain( - 'Successfully read and concatenated content from **1 file(s)**', - ); - }); - - it('should read multiple specified files', async () => { - createFile('file1.txt', 'Content1'); - createFile('subdir/file2.js', 'Content2'); - const params = { paths: ['file1.txt', 'subdir/file2.js'] }; - const invocation = tool.build(params); - const result = await invocation.execute(new AbortController().signal); - const content = result.llmContent as string[]; - const expectedPath1 = path.join(tempRootDir, 'file1.txt'); - const expectedPath2 = path.join(tempRootDir, 'subdir/file2.js'); - expect( - content.some((c) => - c.includes(`--- ${expectedPath1} ---\n\nContent1\n\n`), - ), - ).toBe(true); - expect( - content.some((c) => - c.includes(`--- ${expectedPath2} ---\n\nContent2\n\n`), - ), - ).toBe(true); - expect(result.returnDisplay).toContain( - 'Successfully read and concatenated content from **2 file(s)**', - ); - }); - - it('should handle glob patterns', async () => { - createFile('file.txt', 'Text file'); - createFile('another.txt', 'Another text'); - createFile('sub/data.json', '{}'); - const params = { paths: ['*.txt'] }; - const invocation = tool.build(params); - const result = await invocation.execute(new AbortController().signal); - const content = result.llmContent as string[]; - const expectedPath1 = path.join(tempRootDir, 'file.txt'); - const expectedPath2 = path.join(tempRootDir, 'another.txt'); - expect( - content.some((c) => - c.includes(`--- ${expectedPath1} ---\n\nText file\n\n`), - ), - ).toBe(true); - expect( - content.some((c) => - c.includes(`--- ${expectedPath2} ---\n\nAnother text\n\n`), - ), - ).toBe(true); - expect(content.find((c) => c.includes('sub/data.json'))).toBeUndefined(); - expect(result.returnDisplay).toContain( - 'Successfully read and concatenated content from **2 file(s)**', - ); - }); - - it('should respect exclude patterns', async () => { - createFile('src/main.ts', 'Main content'); - createFile('src/main.test.ts', 'Test content'); - const params = { paths: ['src/**/*.ts'], exclude: ['**/*.test.ts'] }; - const invocation = tool.build(params); - const result = await invocation.execute(new AbortController().signal); - const content = result.llmContent as string[]; - const expectedPath = path.join(tempRootDir, 'src/main.ts'); - expect(content).toEqual([ - `--- ${expectedPath} ---\n\nMain content\n\n`, - `\n--- End of content ---`, - ]); - expect( - content.find((c) => c.includes('src/main.test.ts')), - ).toBeUndefined(); - expect(result.returnDisplay).toContain( - 'Successfully read and concatenated content from **1 file(s)**', - ); - }); - - it('should handle nonexistent specific files gracefully', async () => { - const params = { paths: ['nonexistent-file.txt'] }; - const invocation = tool.build(params); - const result = await invocation.execute(new AbortController().signal); - expect(result.llmContent).toEqual([ - 'No files matching the criteria were found or all were skipped.', - ]); - expect(result.returnDisplay).toContain( - 'No files were read and concatenated based on the criteria.', - ); - }); - - it('should use default excludes', async () => { - createFile('node_modules/some-lib/index.js', 'lib code'); - createFile('src/app.js', 'app code'); - const params = { paths: ['**/*.js'] }; - const invocation = tool.build(params); - const result = await invocation.execute(new AbortController().signal); - const content = result.llmContent as string[]; - const expectedPath = path.join(tempRootDir, 'src/app.js'); - expect(content).toEqual([ - `--- ${expectedPath} ---\n\napp code\n\n`, - `\n--- End of content ---`, - ]); - expect( - content.find((c) => c.includes('node_modules/some-lib/index.js')), - ).toBeUndefined(); - expect(result.returnDisplay).toContain( - 'Successfully read and concatenated content from **1 file(s)**', - ); - }); - - it('should NOT use default excludes if useDefaultExcludes is false', async () => { - createFile('node_modules/some-lib/index.js', 'lib code'); - createFile('src/app.js', 'app code'); - const params = { paths: ['**/*.js'], useDefaultExcludes: false }; - const invocation = tool.build(params); - const result = await invocation.execute(new AbortController().signal); - const content = result.llmContent as string[]; - const expectedPath1 = path.join( - tempRootDir, - 'node_modules/some-lib/index.js', - ); - const expectedPath2 = path.join(tempRootDir, 'src/app.js'); - expect( - content.some((c) => - c.includes(`--- ${expectedPath1} ---\n\nlib code\n\n`), - ), - ).toBe(true); - expect( - content.some((c) => - c.includes(`--- ${expectedPath2} ---\n\napp code\n\n`), - ), - ).toBe(true); - expect(result.returnDisplay).toContain( - 'Successfully read and concatenated content from **2 file(s)**', - ); - }); - - it('should include images as inlineData parts if explicitly requested by extension', async () => { - createBinaryFile( - 'image.png', - Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), - ); - const params = { paths: ['*.png'] }; // Explicitly requesting .png - const invocation = tool.build(params); - const result = await invocation.execute(new AbortController().signal); - expect(result.llmContent).toEqual([ - { - inlineData: { - data: Buffer.from([ - 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, - ]).toString('base64'), - mimeType: 'image/png', - displayName: 'image.png', - }, - }, - '\n--- End of content ---', - ]); - expect(result.returnDisplay).toContain( - 'Successfully read and concatenated content from **1 file(s)**', - ); - }); - - it('should include images as inlineData parts if explicitly requested by name', async () => { - createBinaryFile( - 'myExactImage.png', - Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), - ); - const params = { paths: ['myExactImage.png'] }; // Explicitly requesting by full name - const invocation = tool.build(params); - const result = await invocation.execute(new AbortController().signal); - expect(result.llmContent).toEqual([ - { - inlineData: { - data: Buffer.from([ - 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, - ]).toString('base64'), - mimeType: 'image/png', - displayName: 'myExactImage.png', - }, - }, - '\n--- End of content ---', - ]); - }); - - it('should skip PDF files if not explicitly requested by extension or name', async () => { - createBinaryFile('document.pdf', Buffer.from('%PDF-1.4...')); - createFile('notes.txt', 'text notes'); - const params = { paths: ['*'] }; // Generic glob, not specific to .pdf - const invocation = tool.build(params); - const result = await invocation.execute(new AbortController().signal); - const content = result.llmContent as string[]; - const expectedPath = path.join(tempRootDir, 'notes.txt'); - expect( - content.some( - (c) => - typeof c === 'string' && - c.includes(`--- ${expectedPath} ---\n\ntext notes\n\n`), - ), - ).toBe(true); - expect(result.returnDisplay).toContain('**Skipped 1 item(s):**'); - expect(result.returnDisplay).toContain( - '- `document.pdf` (Reason: asset file (image/pdf) was not explicitly requested by name or extension)', - ); - }); - - it('should include PDF files as inlineData parts if explicitly requested by extension', async () => { - createBinaryFile('important.pdf', Buffer.from('%PDF-1.4...')); - const params = { paths: ['*.pdf'] }; // Explicitly requesting .pdf files - const invocation = tool.build(params); - const result = await invocation.execute(new AbortController().signal); - expect(result.llmContent).toEqual([ - { - inlineData: { - data: Buffer.from('%PDF-1.4...').toString('base64'), - mimeType: 'application/pdf', - displayName: 'important.pdf', - }, - }, - '\n--- End of content ---', - ]); - }); - - it('should include PDF files as inlineData parts if explicitly requested by name', async () => { - createBinaryFile('report-final.pdf', Buffer.from('%PDF-1.4...')); - const params = { paths: ['report-final.pdf'] }; - const invocation = tool.build(params); - const result = await invocation.execute(new AbortController().signal); - expect(result.llmContent).toEqual([ - { - inlineData: { - data: Buffer.from('%PDF-1.4...').toString('base64'), - mimeType: 'application/pdf', - displayName: 'report-final.pdf', - }, - }, - '\n--- End of content ---', - ]); - }); - - it('should return error if path is ignored by a .qwenignore pattern', async () => { - createFile('foo.bar', ''); - createFile('bar.ts', ''); - createFile('foo.quux', ''); - const params = { paths: ['foo.bar', 'bar.ts', 'foo.quux'] }; - const invocation = tool.build(params); - const result = await invocation.execute(new AbortController().signal); - expect(result.returnDisplay).not.toContain('foo.bar'); - expect(result.returnDisplay).not.toContain('foo.quux'); - expect(result.returnDisplay).toContain('bar.ts'); - }); - - it('should read files from multiple workspace directories', async () => { - const tempDir1 = fs.realpathSync( - fs.mkdtempSync(path.join(os.tmpdir(), 'multi-dir-1-')), - ); - const tempDir2 = fs.realpathSync( - fs.mkdtempSync(path.join(os.tmpdir(), 'multi-dir-2-')), - ); - const fileService = new FileDiscoveryService(tempDir1); - const mockConfig = { - getFileService: () => fileService, - getFileSystemService: () => new StandardFileSystemService(), - getFileFilteringOptions: () => ({ - respectGitIgnore: true, - respectQwenIgnore: true, - }), - getWorkspaceContext: () => new WorkspaceContext(tempDir1, [tempDir2]), - getTargetDir: () => tempDir1, - getFileExclusions: () => ({ - getCoreIgnorePatterns: () => COMMON_IGNORE_PATTERNS, - getDefaultExcludePatterns: () => [], - getGlobExcludes: () => COMMON_IGNORE_PATTERNS, - buildExcludePatterns: () => [], - getReadManyFilesExcludes: () => [], - }), - getTruncateToolOutputThreshold: () => 2500, - getTruncateToolOutputLines: () => 500, - } as Partial as Config; - tool = new ReadManyFilesTool(mockConfig); - - fs.writeFileSync(path.join(tempDir1, 'file1.txt'), 'Content1'); - fs.writeFileSync(path.join(tempDir2, 'file2.txt'), 'Content2'); - - const params = { paths: ['*.txt'] }; - const invocation = tool.build(params); - const result = await invocation.execute(new AbortController().signal); - const content = result.llmContent as string[]; - if (!Array.isArray(content)) { - throw new Error(`llmContent is not an array: ${content}`); - } - const expectedPath1 = path.join(tempDir1, 'file1.txt'); - const expectedPath2 = path.join(tempDir2, 'file2.txt'); - - expect( - content.some((c) => - c.includes(`--- ${expectedPath1} ---\n\nContent1\n\n`), - ), - ).toBe(true); - expect( - content.some((c) => - c.includes(`--- ${expectedPath2} ---\n\nContent2\n\n`), - ), - ).toBe(true); - expect(result.returnDisplay).toContain( - 'Successfully read and concatenated content from **2 file(s)**', - ); - - fs.rmSync(tempDir1, { recursive: true, force: true }); - fs.rmSync(tempDir2, { recursive: true, force: true }); - }); - - it('should add a warning for truncated files', async () => { - createFile('file1.txt', 'Content1'); - // Create a file that will be "truncated" by making it long - const longContent = Array.from({ length: 2500 }, (_, i) => `L${i}`).join( - '\n', - ); - createFile('large-file.txt', longContent); - - const params = { paths: ['*.txt'] }; - const invocation = tool.build(params); - const result = await invocation.execute(new AbortController().signal); - const content = result.llmContent as string[]; - - const normalFileContent = content.find((c) => c.includes('file1.txt')); - const truncatedFileContent = content.find((c) => - c.includes('large-file.txt'), - ); - - expect(normalFileContent).not.toContain('Showing lines'); - expect(truncatedFileContent).toContain( - 'Showing lines 1-250 of 2500 total lines.', - ); - }); - - it('should read files with special characters like [] and () in the path', async () => { - const filePath = 'src/app/[test]/(dashboard)/testing/components/code.tsx'; - createFile(filePath, 'Content of receive-detail'); - const params = { paths: [filePath] }; - const invocation = tool.build(params); - const result = await invocation.execute(new AbortController().signal); - const expectedPath = path.join(tempRootDir, filePath); - expect(result.llmContent).toEqual([ - `--- ${expectedPath} --- - -Content of receive-detail - -`, - `\n--- End of content ---`, - ]); - expect(result.returnDisplay).toContain( - 'Successfully read and concatenated content from **1 file(s)**', - ); - }); - - it('should read files with special characters in the name', async () => { - createFile('file[1].txt', 'Content of file[1]'); - const params = { paths: ['file[1].txt'] }; - const invocation = tool.build(params); - const result = await invocation.execute(new AbortController().signal); - const expectedPath = path.join(tempRootDir, 'file[1].txt'); - expect(result.llmContent).toEqual([ - `--- ${expectedPath} --- - -Content of file[1] - -`, - `\n--- End of content ---`, - ]); - expect(result.returnDisplay).toContain( - 'Successfully read and concatenated content from **1 file(s)**', - ); - }); - }); - - describe('Error handling', () => { - it('should return an INVALID_TOOL_PARAMS error if no paths are provided', async () => { - const params = { paths: [], include: [] }; - expect(() => { - tool.build(params); - }).toThrow('params/paths must NOT have fewer than 1 items'); - }); - - it('should return a READ_MANY_FILES_SEARCH_ERROR on glob failure', async () => { - vi.mocked(glob.glob).mockRejectedValue(new Error('Glob failed')); - const params = { paths: ['*.txt'] }; - const invocation = tool.build(params); - const result = await invocation.execute(new AbortController().signal); - expect(result.error?.type).toBe( - ToolErrorType.READ_MANY_FILES_SEARCH_ERROR, - ); - expect(result.llmContent).toBe('Error during file search: Glob failed'); - // Reset glob. - vi.mocked(glob.glob).mockReset(); - }); - }); - - describe('Batch Processing', () => { - const createMultipleFiles = (count: number, contentPrefix = 'Content') => { - const files: string[] = []; - for (let i = 0; i < count; i++) { - const fileName = `file${i}.txt`; - createFile(fileName, `${contentPrefix} ${i}`); - files.push(fileName); - } - return files; - }; - - const createFile = (filePath: string, content = '') => { - const fullPath = path.join(tempRootDir, filePath); - fs.mkdirSync(path.dirname(fullPath), { recursive: true }); - fs.writeFileSync(fullPath, content); - }; - - it('should process files in parallel', async () => { - // Mock detectFileType to add artificial delay to simulate I/O - const detectFileTypeSpy = vi.spyOn( - await import('../utils/fileUtils.js'), - 'detectFileType', - ); - - // Create files - const fileCount = 4; - const files = createMultipleFiles(fileCount, 'Batch test'); - - // Mock with 10ms delay per file to simulate I/O operations - detectFileTypeSpy.mockImplementation(async (_filePath: string) => { - await new Promise((resolve) => setTimeout(resolve, 10)); - return 'text'; - }); - - const params = { paths: files }; - const invocation = tool.build(params); - const result = await invocation.execute(new AbortController().signal); - - // Verify all files were processed. The content should have fileCount - // entries + 1 for the output terminator. - const content = result.llmContent as string[]; - expect(content).toHaveLength(fileCount + 1); - for (let i = 0; i < fileCount; i++) { - expect(content.join('')).toContain(`Batch test ${i}`); - } - - // Cleanup mock - detectFileTypeSpy.mockRestore(); - }); - - it('should handle batch processing errors gracefully', async () => { - // Create mix of valid and problematic files - createFile('valid1.txt', 'Valid content 1'); - createFile('valid2.txt', 'Valid content 2'); - createFile('valid3.txt', 'Valid content 3'); - - const params = { - paths: [ - 'valid1.txt', - 'valid2.txt', - 'nonexistent-file.txt', // This will fail - 'valid3.txt', - ], - }; - - const invocation = tool.build(params); - const result = await invocation.execute(new AbortController().signal); - const content = result.llmContent as string[]; - - // Should successfully process valid files despite one failure - expect(content.length).toBeGreaterThanOrEqual(3); - expect(result.returnDisplay).toContain('Successfully read'); - - // Verify valid files were processed - const expectedPath1 = path.join(tempRootDir, 'valid1.txt'); - const expectedPath3 = path.join(tempRootDir, 'valid3.txt'); - expect(content.some((c) => c.includes(expectedPath1))).toBe(true); - expect(content.some((c) => c.includes(expectedPath3))).toBe(true); - }); - - it('should execute file operations concurrently', async () => { - // Track execution order to verify concurrency - const executionOrder: string[] = []; - const detectFileTypeSpy = vi.spyOn( - await import('../utils/fileUtils.js'), - 'detectFileType', - ); - - const files = ['file1.txt', 'file2.txt', 'file3.txt']; - files.forEach((file) => createFile(file, 'test content')); - - // Mock to track concurrent vs sequential execution - detectFileTypeSpy.mockImplementation(async (filePath: string) => { - const fileName = filePath.split('/').pop() || ''; - executionOrder.push(`start:${fileName}`); - - // Add delay to make timing differences visible - await new Promise((resolve) => setTimeout(resolve, 50)); - - executionOrder.push(`end:${fileName}`); - return 'text'; - }); - - const invocation = tool.build({ paths: files }); - await invocation.execute(new AbortController().signal); - - console.log('Execution order:', executionOrder); - - // Verify concurrent execution pattern - // In parallel execution: all "start:" events should come before all "end:" events - // In sequential execution: "start:file1", "end:file1", "start:file2", "end:file2", etc. - - const startEvents = executionOrder.filter((e) => - e.startsWith('start:'), - ).length; - const firstEndIndex = executionOrder.findIndex((e) => - e.startsWith('end:'), - ); - const startsBeforeFirstEnd = executionOrder - .slice(0, firstEndIndex) - .filter((e) => e.startsWith('start:')).length; - - // For parallel processing, ALL start events should happen before the first end event - expect(startsBeforeFirstEnd).toBe(startEvents); // Should PASS with parallel implementation - - detectFileTypeSpy.mockRestore(); - }); - }); -}); diff --git a/packages/core/src/tools/read-many-files.ts b/packages/core/src/tools/read-many-files.ts deleted file mode 100644 index 33ea33399..000000000 --- a/packages/core/src/tools/read-many-files.ts +++ /dev/null @@ -1,578 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { ToolInvocation, ToolResult } from './tools.js'; -import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js'; -import { ToolNames, ToolDisplayNames } from './tool-names.js'; -import { getErrorMessage } from '../utils/errors.js'; -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import { glob, escape } from 'glob'; -import type { ProcessedFileReadResult } from '../utils/fileUtils.js'; -import { - detectFileType, - processSingleFileContent, - DEFAULT_ENCODING, - getSpecificMimeType, -} from '../utils/fileUtils.js'; -import type { PartListUnion } from '@google/genai'; -import { - type Config, - DEFAULT_FILE_FILTERING_OPTIONS, -} from '../config/config.js'; -import { FileOperation } from '../telemetry/metrics.js'; -import { getProgrammingLanguage } from '../telemetry/telemetry-utils.js'; -import { logFileOperation } from '../telemetry/loggers.js'; -import { FileOperationEvent } from '../telemetry/types.js'; -import { ToolErrorType } from './tool-error.js'; - -/** - * Parameters for the ReadManyFilesTool. - */ -export interface ReadManyFilesParams { - /** - * An array of file paths or directory paths to search within. - * Paths are relative to the tool's configured target directory. - * Glob patterns can be used directly in these paths. - */ - paths: string[]; - - /** - * Optional. Glob patterns for files to include. - * These are effectively combined with the `paths`. - * Example: ["*.ts", "src/** /*.md"] - */ - include?: string[]; - - /** - * Optional. Glob patterns for files/directories to exclude. - * Applied as ignore patterns. - * Example: ["*.log", "dist/**"] - */ - exclude?: string[]; - - /** - * Optional. Search directories recursively. - * This is generally controlled by glob patterns (e.g., `**`). - * The glob implementation is recursive by default for `**`. - * For simplicity, we'll rely on `**` for recursion. - */ - recursive?: boolean; - - /** - * Optional. Apply default exclusion patterns. Defaults to true. - */ - useDefaultExcludes?: boolean; - - /** - * Whether to respect .gitignore and .qwenignore patterns (optional, defaults to true) - */ - file_filtering_options?: { - respect_git_ignore?: boolean; - respect_qwen_ignore?: boolean; - }; -} - -/** - * Result type for file processing operations - */ -type FileProcessingResult = - | { - success: true; - filePath: string; - relativePathForDisplay: string; - fileReadResult: ProcessedFileReadResult; - reason?: undefined; - } - | { - success: false; - filePath: string; - relativePathForDisplay: string; - fileReadResult?: undefined; - reason: string; - }; - -/** - * Creates the default exclusion patterns including dynamic patterns. - * This combines the shared patterns with dynamic patterns like QWEN.md. - * TODO(adh): Consider making this configurable or extendable through a command line argument. - */ -function getDefaultExcludes(config?: Config): string[] { - return config?.getFileExclusions().getReadManyFilesExcludes() ?? []; -} - -const DEFAULT_OUTPUT_SEPARATOR_FORMAT = '--- {filePath} ---'; -const DEFAULT_OUTPUT_TERMINATOR = '\n--- End of content ---'; - -class ReadManyFilesToolInvocation extends BaseToolInvocation< - ReadManyFilesParams, - ToolResult -> { - constructor( - private readonly config: Config, - params: ReadManyFilesParams, - ) { - super(params); - } - - getDescription(): string { - const allPatterns = [...this.params.paths, ...(this.params.include || [])]; - const pathDesc = `using patterns: -${allPatterns.join('`, `')} - (within target directory: -${this.config.getTargetDir()} -) `; - - // Determine the final list of exclusion patterns exactly as in execute method - const paramExcludes = this.params.exclude || []; - const paramUseDefaultExcludes = this.params.useDefaultExcludes !== false; - const qwenIgnorePatterns = this.config - .getFileService() - .getQwenIgnorePatterns(); - const finalExclusionPatternsForDescription: string[] = - paramUseDefaultExcludes - ? [ - ...getDefaultExcludes(this.config), - ...paramExcludes, - ...qwenIgnorePatterns, - ] - : [...paramExcludes, ...qwenIgnorePatterns]; - - let excludeDesc = `Excluding: ${ - finalExclusionPatternsForDescription.length > 0 - ? `patterns like -${finalExclusionPatternsForDescription - .slice(0, 2) - .join( - '`, `', - )}${finalExclusionPatternsForDescription.length > 2 ? '...`' : '`'}` - : 'none specified' - }`; - - // Add a note if .qwenignore patterns contributed to the final list of exclusions - if (qwenIgnorePatterns.length > 0) { - const geminiPatternsInEffect = qwenIgnorePatterns.filter((p) => - finalExclusionPatternsForDescription.includes(p), - ).length; - if (geminiPatternsInEffect > 0) { - excludeDesc += ` (includes ${geminiPatternsInEffect} from .qwenignore)`; - } - } - - return `Will attempt to read and concatenate files ${pathDesc}. ${excludeDesc}. File encoding: ${DEFAULT_ENCODING}. Separator: "${DEFAULT_OUTPUT_SEPARATOR_FORMAT.replace( - '{filePath}', - 'path/to/file.ext', - )}".`; - } - - async execute(signal: AbortSignal): Promise { - const { - paths: inputPatterns, - include = [], - exclude = [], - useDefaultExcludes = true, - } = this.params; - - const filesToConsider = new Set(); - const skippedFiles: Array<{ path: string; reason: string }> = []; - const processedFilesRelativePaths: string[] = []; - const contentParts: PartListUnion = []; - - const effectiveExcludes = useDefaultExcludes - ? [...getDefaultExcludes(this.config), ...exclude] - : [...exclude]; - - const searchPatterns = [...inputPatterns, ...include]; - try { - const allEntries = new Set(); - const workspaceDirs = this.config.getWorkspaceContext().getDirectories(); - - for (const dir of workspaceDirs) { - const processedPatterns = []; - for (const p of searchPatterns) { - const normalizedP = p.replace(/\\/g, '/'); - const fullPath = path.join(dir, normalizedP); - if (fs.existsSync(fullPath)) { - processedPatterns.push(escape(normalizedP)); - } else { - // The path does not exist or is not a file, so we treat it as a glob pattern. - processedPatterns.push(normalizedP); - } - } - - const entriesInDir = await glob(processedPatterns, { - cwd: dir, - ignore: effectiveExcludes, - nodir: true, - dot: true, - absolute: true, - nocase: true, - signal, - }); - for (const entry of entriesInDir) { - allEntries.add(entry); - } - } - const relativeEntries = Array.from(allEntries).map((p) => - path.relative(this.config.getTargetDir(), p), - ); - - const fileDiscovery = this.config.getFileService(); - const { filteredPaths, gitIgnoredCount, qwenIgnoredCount } = - fileDiscovery.filterFilesWithReport(relativeEntries, { - respectGitIgnore: - this.params.file_filtering_options?.respect_git_ignore ?? - this.config.getFileFilteringOptions().respectGitIgnore ?? - DEFAULT_FILE_FILTERING_OPTIONS.respectGitIgnore, - respectQwenIgnore: - this.params.file_filtering_options?.respect_qwen_ignore ?? - this.config.getFileFilteringOptions().respectQwenIgnore ?? - DEFAULT_FILE_FILTERING_OPTIONS.respectQwenIgnore, - }); - - for (const relativePath of filteredPaths) { - // Security check: ensure the glob library didn't return something outside the workspace. - - const fullPath = path.resolve(this.config.getTargetDir(), relativePath); - if ( - !this.config.getWorkspaceContext().isPathWithinWorkspace(fullPath) - ) { - skippedFiles.push({ - path: fullPath, - reason: `Security: Glob library returned path outside workspace. Path: ${fullPath}`, - }); - continue; - } - filesToConsider.add(fullPath); - } - - // Add info about git-ignored files if any were filtered - if (gitIgnoredCount > 0) { - skippedFiles.push({ - path: `${gitIgnoredCount} file(s)`, - reason: 'git ignored', - }); - } - - // Add info about qwen-ignored files if any were filtered - if (qwenIgnoredCount > 0) { - skippedFiles.push({ - path: `${qwenIgnoredCount} file(s)`, - reason: 'qwen ignored', - }); - } - } catch (error) { - const errorMessage = `Error during file search: ${getErrorMessage(error)}`; - return { - llmContent: errorMessage, - returnDisplay: `## File Search Error\n\nAn error occurred while searching for files:\n\`\`\`\n${getErrorMessage(error)}\n\`\`\``, - error: { - message: errorMessage, - type: ToolErrorType.READ_MANY_FILES_SEARCH_ERROR, - }, - }; - } - - const sortedFiles = Array.from(filesToConsider).sort(); - const truncateToolOutputLines = this.config.getTruncateToolOutputLines(); - const file_line_limit = Number.isFinite(truncateToolOutputLines) - ? Math.floor(truncateToolOutputLines / Math.max(1, sortedFiles.length)) - : undefined; - - const fileProcessingPromises = sortedFiles.map( - async (filePath): Promise => { - try { - const relativePathForDisplay = path - .relative(this.config.getTargetDir(), filePath) - .replace(/\\/g, '/'); - - const fileType = await detectFileType(filePath); - - if (fileType === 'image' || fileType === 'pdf') { - const fileExtension = path.extname(filePath).toLowerCase(); - const fileNameWithoutExtension = path.basename( - filePath, - fileExtension, - ); - const requestedExplicitly = inputPatterns.some( - (pattern: string) => - pattern.toLowerCase().includes(fileExtension) || - pattern.includes(fileNameWithoutExtension), - ); - - if (!requestedExplicitly) { - return { - success: false, - filePath, - relativePathForDisplay, - reason: - 'asset file (image/pdf) was not explicitly requested by name or extension', - }; - } - } - - // Use processSingleFileContent for all file types now - const fileReadResult = await processSingleFileContent( - filePath, - this.config, - 0, - file_line_limit, - ); - - if (fileReadResult.error) { - return { - success: false, - filePath, - relativePathForDisplay, - reason: `Read error: ${fileReadResult.error}`, - }; - } - - return { - success: true, - filePath, - relativePathForDisplay, - fileReadResult, - }; - } catch (error) { - const relativePathForDisplay = path - .relative(this.config.getTargetDir(), filePath) - .replace(/\\/g, '/'); - - return { - success: false, - filePath, - relativePathForDisplay, - reason: `Unexpected error: ${error instanceof Error ? error.message : String(error)}`, - }; - } - }, - ); - - const results = await Promise.allSettled(fileProcessingPromises); - - for (const result of results) { - if (result.status === 'fulfilled') { - const fileResult = result.value; - - if (!fileResult.success) { - // Handle skipped files (images/PDFs not requested or read errors) - skippedFiles.push({ - path: fileResult.relativePathForDisplay, - reason: fileResult.reason, - }); - } else { - // Handle successfully processed files - const { filePath, relativePathForDisplay, fileReadResult } = - fileResult; - - if (typeof fileReadResult.llmContent === 'string') { - const separator = DEFAULT_OUTPUT_SEPARATOR_FORMAT.replace( - '{filePath}', - filePath, - ); - let fileContentForLlm = ''; - if (fileReadResult.isTruncated) { - const [start, end] = fileReadResult.linesShown!; - const total = fileReadResult.originalLineCount!; - fileContentForLlm = `Showing lines ${start}-${end} of ${total} total lines.\n---\n${fileReadResult.llmContent}`; - } else { - fileContentForLlm = fileReadResult.llmContent; - } - contentParts.push(`${separator}\n\n${fileContentForLlm}\n\n`); - } else { - // This is a Part for image/pdf, which we don't add the separator to. - contentParts.push(fileReadResult.llmContent); - } - - processedFilesRelativePaths.push(relativePathForDisplay); - - const lines = - typeof fileReadResult.llmContent === 'string' - ? fileReadResult.llmContent.split('\n').length - : undefined; - const mimetype = getSpecificMimeType(filePath); - const programming_language = getProgrammingLanguage({ - absolute_path: filePath, - }); - logFileOperation( - this.config, - new FileOperationEvent( - ReadManyFilesTool.Name, - FileOperation.READ, - lines, - mimetype, - path.extname(filePath), - programming_language, - ), - ); - } - } else { - // Handle Promise rejection (unexpected errors) - skippedFiles.push({ - path: 'unknown', - reason: `Unexpected error: ${result.reason}`, - }); - } - } - - let displayMessage = `### ReadManyFiles Result (Target Dir: \`${this.config.getTargetDir()}\`)\n\n`; - if (processedFilesRelativePaths.length > 0) { - displayMessage += `Successfully read and concatenated content from **${processedFilesRelativePaths.length} file(s)**.\n`; - if (processedFilesRelativePaths.length <= 10) { - displayMessage += `\n**Processed Files:**\n`; - processedFilesRelativePaths.forEach( - (p) => (displayMessage += `- \`${p}\`\n`), - ); - } else { - displayMessage += `\n**Processed Files (first 10 shown):**\n`; - processedFilesRelativePaths - .slice(0, 10) - .forEach((p) => (displayMessage += `- \`${p}\`\n`)); - displayMessage += `- ...and ${processedFilesRelativePaths.length - 10} more.\n`; - } - } - - if (skippedFiles.length > 0) { - if (processedFilesRelativePaths.length === 0) { - displayMessage += `No files were read and concatenated based on the criteria.\n`; - } - if (skippedFiles.length <= 5) { - displayMessage += `\n**Skipped ${skippedFiles.length} item(s):**\n`; - } else { - displayMessage += `\n**Skipped ${skippedFiles.length} item(s) (first 5 shown):**\n`; - } - skippedFiles - .slice(0, 5) - .forEach( - (f) => (displayMessage += `- \`${f.path}\` (Reason: ${f.reason})\n`), - ); - if (skippedFiles.length > 5) { - displayMessage += `- ...and ${skippedFiles.length - 5} more.\n`; - } - } else if ( - processedFilesRelativePaths.length === 0 && - skippedFiles.length === 0 - ) { - displayMessage += `No files were read and concatenated based on the criteria.\n`; - } - - if (contentParts.length > 0) { - contentParts.push(DEFAULT_OUTPUT_TERMINATOR); - } else { - contentParts.push( - 'No files matching the criteria were found or all were skipped.', - ); - } - return { - llmContent: contentParts, - returnDisplay: displayMessage.trim(), - }; - } -} - -/** - * Tool implementation for finding and reading multiple text files from the local filesystem - * within a specified target directory. The content is concatenated. - * It is intended to run in an environment with access to the local file system (e.g., a Node.js backend). - */ -export class ReadManyFilesTool extends BaseDeclarativeTool< - ReadManyFilesParams, - ToolResult -> { - static readonly Name: string = ToolNames.READ_MANY_FILES; - - constructor(private config: Config) { - const parameterSchema = { - type: 'object', - properties: { - paths: { - type: 'array', - items: { - type: 'string', - minLength: 1, - }, - minItems: 1, - description: - "Required. An array of glob patterns or paths relative to the tool's target directory. Examples: ['src/**/*.ts'], ['README.md', 'docs/']", - }, - include: { - type: 'array', - items: { - type: 'string', - minLength: 1, - }, - description: - 'Optional. Additional glob patterns to include. These are merged with `paths`. Example: "*.test.ts" to specifically add test files if they were broadly excluded.', - default: [], - }, - exclude: { - type: 'array', - items: { - type: 'string', - minLength: 1, - }, - description: - 'Optional. Glob patterns for files/directories to exclude. Added to default excludes if useDefaultExcludes is true. Example: "**/*.log", "temp/"', - default: [], - }, - recursive: { - type: 'boolean', - description: - 'Optional. Whether to search recursively (primarily controlled by `**` in glob patterns). Defaults to true.', - default: true, - }, - useDefaultExcludes: { - type: 'boolean', - description: - 'Optional. Whether to apply a list of default exclusion patterns (e.g., node_modules, .git, binary files). Defaults to true.', - default: true, - }, - file_filtering_options: { - description: - 'Whether to respect ignore patterns from .gitignore or .qwenignore', - type: 'object', - properties: { - respect_git_ignore: { - description: - 'Optional: Whether to respect .gitignore patterns when listing files. Only available in git repositories. Defaults to true.', - type: 'boolean', - }, - respect_qwen_ignore: { - description: - 'Optional: Whether to respect .qwenignore patterns when listing files. Defaults to true.', - type: 'boolean', - }, - }, - }, - }, - required: ['paths'], - }; - - super( - ReadManyFilesTool.Name, - ToolDisplayNames.READ_MANY_FILES, - `Reads content from multiple files specified by paths or glob patterns within a configured target directory. For text files, it concatenates their content into a single string. It is primarily designed for text-based files. However, it can also process image (e.g., .png, .jpg) and PDF (.pdf) files if their file names or extensions are explicitly included in the 'paths' argument. For these explicitly requested non-text files, their data is read and included in a format suitable for model consumption (e.g., base64 encoded). - -This tool is useful when you need to understand or analyze a collection of files, such as: -- Getting an overview of a codebase or parts of it (e.g., all TypeScript files in the 'src' directory). -- Finding where specific functionality is implemented if the user asks broad questions about code. -- Reviewing documentation files (e.g., all Markdown files in the 'docs' directory). -- Gathering context from multiple configuration files. -- When the user asks to "read all files in X directory" or "show me the content of all Y files". - -Use this tool when the user's query implies needing the content of several files simultaneously for context, analysis, or summarization. For text files, it uses default UTF-8 encoding and a '--- {filePath} ---' separator between file contents. The tool inserts a '--- End of content ---' after the last file. Ensure paths are relative to the target directory. Glob patterns like 'src/**/*.js' are supported. Avoid using for single files if a more specific single-file reading tool is available, unless the user specifically requests to process a list containing just one file via this tool. Other binary files (not explicitly requested as image/PDF) are generally skipped. Default excludes apply to common non-text files (except for explicitly requested images/PDFs) and large dependency directories unless 'useDefaultExcludes' is false.`, - Kind.Read, - parameterSchema, - ); - } - - protected createInvocation( - params: ReadManyFilesParams, - ): ToolInvocation { - return new ReadManyFilesToolInvocation(this.config, params); - } -} diff --git a/packages/core/src/tools/tool-error.ts b/packages/core/src/tools/tool-error.ts index 27dc42851..a07de4777 100644 --- a/packages/core/src/tools/tool-error.ts +++ b/packages/core/src/tools/tool-error.ts @@ -53,9 +53,6 @@ export enum ToolErrorType { // Memory-specific Errors MEMORY_TOOL_EXECUTION_ERROR = 'memory_tool_execution_error', - // ReadManyFiles-specific Errors - READ_MANY_FILES_SEARCH_ERROR = 'read_many_files_search_error', - // Shell errors SHELL_EXECUTE_ERROR = 'shell_execute_error', diff --git a/packages/core/src/tools/tool-names.ts b/packages/core/src/tools/tool-names.ts index 7976ba461..3399f7d41 100644 --- a/packages/core/src/tools/tool-names.ts +++ b/packages/core/src/tools/tool-names.ts @@ -13,7 +13,6 @@ export const ToolNames = { EDIT: 'edit', WRITE_FILE: 'write_file', READ_FILE: 'read_file', - READ_MANY_FILES: 'read_many_files', GREP: 'grep_search', GLOB: 'glob', SHELL: 'run_shell_command', @@ -37,7 +36,6 @@ export const ToolDisplayNames = { EDIT: 'Edit', WRITE_FILE: 'WriteFile', READ_FILE: 'ReadFile', - READ_MANY_FILES: 'ReadManyFiles', GREP: 'Grep', GLOB: 'Glob', SHELL: 'Shell', diff --git a/packages/core/src/utils/environmentContext.test.ts b/packages/core/src/utils/environmentContext.test.ts index 944e0906a..0b24a9b01 100644 --- a/packages/core/src/utils/environmentContext.test.ts +++ b/packages/core/src/utils/environmentContext.test.ts @@ -75,7 +75,6 @@ describe('getDirectoryContextString', () => { describe('getEnvironmentContext', () => { let mockConfig: Partial; - let mockToolRegistry: { getTool: Mock }; beforeEach(() => { vi.useFakeTimers(); @@ -89,17 +88,11 @@ describe('getEnvironmentContext', () => { })), }); - mockToolRegistry = { - getTool: vi.fn(), - }; - mockConfig = { getWorkspaceContext: vi.fn().mockReturnValue({ getDirectories: vi.fn().mockReturnValue(['/test/dir']), }), getFileService: vi.fn(), - getFullContext: vi.fn().mockReturnValue(false), - getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry), }; vi.mocked(getFolderStructure).mockResolvedValue('Mock Folder Structure'); @@ -152,68 +145,6 @@ describe('getEnvironmentContext', () => { ); expect(getFolderStructure).toHaveBeenCalledTimes(2); }); - - it('should include full file context when getFullContext is true', async () => { - mockConfig.getFullContext = vi.fn().mockReturnValue(true); - const mockReadManyFilesTool = { - build: vi.fn().mockReturnValue({ - execute: vi - .fn() - .mockResolvedValue({ llmContent: 'Full file content here' }), - }), - }; - mockToolRegistry.getTool.mockReturnValue(mockReadManyFilesTool); - - const parts = await getEnvironmentContext(mockConfig as Config); - - expect(parts.length).toBe(2); - expect(parts[1].text).toBe( - '\n--- Full File Context ---\nFull file content here', - ); - expect(mockToolRegistry.getTool).toHaveBeenCalledWith('read_many_files'); - expect(mockReadManyFilesTool.build).toHaveBeenCalledWith({ - paths: ['**/*'], - useDefaultExcludes: true, - }); - }); - - it('should handle read_many_files returning no content', async () => { - mockConfig.getFullContext = vi.fn().mockReturnValue(true); - const mockReadManyFilesTool = { - build: vi.fn().mockReturnValue({ - execute: vi.fn().mockResolvedValue({ llmContent: '' }), - }), - }; - mockToolRegistry.getTool.mockReturnValue(mockReadManyFilesTool); - - const parts = await getEnvironmentContext(mockConfig as Config); - - expect(parts.length).toBe(1); // No extra part added - }); - - it('should handle read_many_files tool not being found', async () => { - mockConfig.getFullContext = vi.fn().mockReturnValue(true); - mockToolRegistry.getTool.mockReturnValue(null); - - const parts = await getEnvironmentContext(mockConfig as Config); - - expect(parts.length).toBe(1); // No extra part added - }); - - it('should handle errors when reading full file context', async () => { - mockConfig.getFullContext = vi.fn().mockReturnValue(true); - const mockReadManyFilesTool = { - build: vi.fn().mockReturnValue({ - execute: vi.fn().mockRejectedValue(new Error('Read error')), - }), - }; - mockToolRegistry.getTool.mockReturnValue(mockReadManyFilesTool); - - const parts = await getEnvironmentContext(mockConfig as Config); - - expect(parts.length).toBe(2); - expect(parts[1].text).toBe('\n--- Error reading full file context ---'); - }); }); describe('getInitialChatHistory', () => { @@ -227,8 +158,6 @@ describe('getInitialChatHistory', () => { getDirectories: vi.fn().mockReturnValue(['/test/dir']), }), getFileService: vi.fn(), - getFullContext: vi.fn().mockReturnValue(false), - getToolRegistry: vi.fn().mockReturnValue({ getTool: vi.fn() }), }; }); @@ -267,16 +196,6 @@ describe('getInitialChatHistory', () => { 'getWorkspaceContext should not be called when skipping startup context', ); }); - mockConfig.getFullContext = vi.fn(() => { - throw new Error( - 'getFullContext should not be called when skipping startup context', - ); - }); - mockConfig.getToolRegistry = vi.fn(() => { - throw new Error( - 'getToolRegistry should not be called when skipping startup context', - ); - }); const extraHistory: Content[] = [ { role: 'user', parts: [{ text: 'custom context' }] }, ]; @@ -298,16 +217,6 @@ describe('getInitialChatHistory', () => { 'getWorkspaceContext should not be called when skipping startup context', ); }); - mockConfig.getFullContext = vi.fn(() => { - throw new Error( - 'getFullContext should not be called when skipping startup context', - ); - }); - mockConfig.getToolRegistry = vi.fn(() => { - throw new Error( - 'getToolRegistry should not be called when skipping startup context', - ); - }); const history = await getInitialChatHistory(mockConfig as Config); diff --git a/packages/core/src/utils/environmentContext.ts b/packages/core/src/utils/environmentContext.ts index 2bbe12dd9..4f5c03209 100644 --- a/packages/core/src/utils/environmentContext.ts +++ b/packages/core/src/utils/environmentContext.ts @@ -46,7 +46,6 @@ ${folderStructure}`; /** * Retrieves environment-related information to be included in the chat context. * This includes the current working directory, date, operating system, and folder structure. - * Optionally, it can also include the full file context if enabled. * @param {Config} config - The runtime configuration and services. * @returns A promise that resolves to an array of `Part` objects containing environment information. */ @@ -67,45 +66,7 @@ My operating system is: ${platform} ${directoryContext} `.trim(); - const initialParts: Part[] = [{ text: context }]; - const toolRegistry = config.getToolRegistry(); - - // Add full file context if the flag is set - if (config.getFullContext()) { - try { - const readManyFilesTool = toolRegistry.getTool('read_many_files'); - if (readManyFilesTool) { - const invocation = readManyFilesTool.build({ - paths: ['**/*'], // Read everything recursively - useDefaultExcludes: true, // Use default excludes - }); - - // Read all files in the target directory - const result = await invocation.execute(AbortSignal.timeout(30000)); - if (result.llmContent) { - initialParts.push({ - text: `\n--- Full File Context ---\n${result.llmContent}`, - }); - } else { - console.warn( - 'Full context requested, but read_many_files returned no content.', - ); - } - } else { - console.warn( - 'Full context requested, but read_many_files tool not found.', - ); - } - } catch (error) { - // Not using reportError here as it's a startup/config phase, not a chat/generation phase error. - console.error('Error reading full file context:', error); - initialParts.push({ - text: '\n--- Error reading full file context ---', - }); - } - } - - return initialParts; + return [{ text: context }]; } export async function getInitialChatHistory( diff --git a/packages/core/src/utils/getFolderStructure.test.ts b/packages/core/src/utils/getFolderStructure.test.ts index 1c8332bd7..df6d0798f 100644 --- a/packages/core/src/utils/getFolderStructure.test.ts +++ b/packages/core/src/utils/getFolderStructure.test.ts @@ -45,7 +45,7 @@ describe('getFolderStructure', () => { const structure = await getFolderStructure(testRootDir); expect(structure.trim()).toBe( ` -Showing up to 20 items (files + folders). +Showing up to 20 items: ${testRootDir}${path.sep} ├───fileA1.ts @@ -60,7 +60,7 @@ ${testRootDir}${path.sep} const structure = await getFolderStructure(testRootDir); expect(structure.trim()).toBe( ` -Showing up to 20 items (files + folders). +Showing up to 20 items: ${testRootDir}${path.sep} ` @@ -81,7 +81,7 @@ ${testRootDir}${path.sep} const structure = await getFolderStructure(testRootDir); expect(structure.trim()).toBe( ` -Showing up to 20 items (files + folders). Folders or files indicated with ... contain more items not shown, were ignored, or the display limit (20 items) was reached. +Showing up to 20 items: ${testRootDir}${path.sep} ├───.hiddenfile @@ -108,7 +108,7 @@ ${testRootDir}${path.sep} ignoredFolders: new Set(['subfolderA', 'node_modules']), }); const expected = ` -Showing up to 20 items (files + folders). Folders or files indicated with ... contain more items not shown, were ignored, or the display limit (20 items) was reached. +Showing up to 20 items: ${testRootDir}${path.sep} ├───.hiddenfile @@ -129,7 +129,7 @@ ${testRootDir}${path.sep} fileIncludePattern: /\.ts$/, }); const expected = ` -Showing up to 20 items (files + folders). +Showing up to 20 items: ${testRootDir}${path.sep} ├───fileA1.ts @@ -147,7 +147,7 @@ ${testRootDir}${path.sep} maxItems: 3, }); const expected = ` -Showing up to 3 items (files + folders). +Showing up to 3 items: ${testRootDir}${path.sep} ├───fileA1.ts @@ -166,7 +166,7 @@ ${testRootDir}${path.sep} maxItems: 4, }); const expectedRevised = ` -Showing up to 4 items (files + folders). Folders or files indicated with ... contain more items not shown, were ignored, or the display limit (4 items) was reached. +Showing up to 4 items: ${testRootDir}${path.sep} ├───folder-0${path.sep} @@ -187,7 +187,7 @@ ${testRootDir}${path.sep} maxItems: 1, }); const expected = ` -Showing up to 1 items (files + folders). Folders or files indicated with ... contain more items not shown, were ignored, or the display limit (1 items) was reached. +Showing up to 1 items: ${testRootDir}${path.sep} ├───fileA1.ts @@ -212,7 +212,7 @@ ${testRootDir}${path.sep} maxItems: 10, }); const expected = ` -Showing up to 10 items (files + folders). +Showing up to 10 items: ${testRootDir}${path.sep} └───level1${path.sep} @@ -230,7 +230,7 @@ ${testRootDir}${path.sep} maxItems: 3, }); const expected = ` -Showing up to 3 items (files + folders). +Showing up to 3 items: ${testRootDir}${path.sep} └───level1${path.sep} diff --git a/packages/core/src/utils/getFolderStructure.ts b/packages/core/src/utils/getFolderStructure.ts index 20b0ea130..db356439d 100644 --- a/packages/core/src/utils/getFolderStructure.ts +++ b/packages/core/src/utils/getFolderStructure.ts @@ -322,25 +322,7 @@ export async function getFolderStructure( formatStructure(structureRoot, '', true, true, structureLines); // 3. Build the final output string - function isTruncated(node: FullFolderInfo): boolean { - if (node.hasMoreFiles || node.hasMoreSubfolders || node.isIgnored) { - return true; - } - for (const sub of node.subFolders) { - if (isTruncated(sub)) { - return true; - } - } - return false; - } - - let summary = `Showing up to ${mergedOptions.maxItems} items (files + folders).`; - - if (isTruncated(structureRoot)) { - summary += ` Folders or files indicated with ${TRUNCATION_INDICATOR} contain more items not shown, were ignored, or the display limit (${mergedOptions.maxItems} items) was reached.`; - } - - return `${summary}\n\n${resolvedPath}${path.sep}\n${structureLines.join('\n')}`; + return `Showing up to ${mergedOptions.maxItems} items:\n\n${resolvedPath}${path.sep}\n${structureLines.join('\n')}`; } catch (error: unknown) { console.error(`Error getting folder structure for ${resolvedPath}:`, error); return `Error processing directory "${resolvedPath}": ${getErrorMessage(error)}`; diff --git a/packages/core/src/utils/readManyFiles.test.ts b/packages/core/src/utils/readManyFiles.test.ts new file mode 100644 index 000000000..859753fef --- /dev/null +++ b/packages/core/src/utils/readManyFiles.test.ts @@ -0,0 +1,298 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs/promises'; +import * as nodeFs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import type { PartListUnion } from '@google/genai'; +import { readManyFiles } from './readManyFiles.js'; +import { FileDiscoveryService } from '../services/fileDiscoveryService.js'; +import type { Config } from '../config/config.js'; +import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js'; + +/** Helper to convert PartListUnion to string for test assertions */ +function contentToString(parts: PartListUnion): string { + if (typeof parts === 'string') { + return parts; + } + if (Array.isArray(parts)) { + return parts + .map((p) => (typeof p === 'string' ? p : JSON.stringify(p))) + .join(''); + } + return JSON.stringify(parts); +} + +describe('readManyFiles', () => { + let tempRootDir: string; + + // Helper to create mock config + const createMockConfig = (rootDir: string): Config => + ({ + getFileService: () => new FileDiscoveryService(rootDir), + getFileFilteringOptions: () => ({ + respectGitIgnore: true, + respectQwenIgnore: true, + }), + getTargetDir: () => rootDir, + getProjectRoot: () => rootDir, + getWorkspaceContext: () => createMockWorkspaceContext(rootDir), + getTruncateToolOutputLines: () => 1000, + getTruncateToolOutputThreshold: () => 2500, + }) as unknown as Config; + + async function createTestFile( + ...pathSegments: string[] + ): Promise<{ relativePath: string; absolutePath: string }> { + const relativePath = path.join(...pathSegments); + const absolutePath = path.join(tempRootDir, relativePath); + await fs.mkdir(path.dirname(absolutePath), { recursive: true }); + await fs.writeFile(absolutePath, `Content of ${pathSegments.at(-1)}`); + return { relativePath, absolutePath }; + } + + async function createTestDir(...pathSegments: string[]): Promise { + const absolutePath = path.join(tempRootDir, ...pathSegments); + await fs.mkdir(absolutePath, { recursive: true }); + return absolutePath; + } + + beforeEach(async () => { + tempRootDir = nodeFs.realpathSync( + await fs.mkdtemp(path.join(os.tmpdir(), 'read-many-files-test-')), + ); + }); + + afterEach(async () => { + await fs.rm(tempRootDir, { recursive: true, force: true }); + }); + + describe('file reading', () => { + it('should read a single file', async () => { + await createTestFile('file1.txt'); + const mockConfig = createMockConfig(tempRootDir); + + const result = await readManyFiles(mockConfig, { paths: ['file1.txt'] }); + + const content = contentToString(result.contentParts); + expect(content).toContain('--- Content from referenced files ---'); + expect(content).toContain('Content from'); + expect(content).toContain('file1.txt'); + expect(content).toContain('Content of file1.txt'); + expect(content).toContain('--- End of content ---'); + }); + + it('should read multiple files', async () => { + await createTestFile('file1.txt'); + await createTestFile('file2.txt'); + const mockConfig = createMockConfig(tempRootDir); + + const result = await readManyFiles(mockConfig, { + paths: ['file1.txt', 'file2.txt'], + }); + + const content = contentToString(result.contentParts); + expect(content).toContain('--- Content from referenced files ---'); + expect(content).toContain('Content of file1.txt'); + expect(content).toContain('Content of file2.txt'); + expect(content).toContain('--- End of content ---'); + }); + + it('should return message when no files found', async () => { + const mockConfig = createMockConfig(tempRootDir); + + const result = await readManyFiles(mockConfig, { + paths: ['nonexistent.txt'], + }); + + expect(contentToString(result.contentParts)).toContain( + 'No files matching the criteria were found', + ); + }); + }); + + describe('directory handling', () => { + it('should return directory structure when path is a directory', async () => { + await createTestFile('mydir', 'file1.txt'); + await createTestFile('mydir', 'file2.txt'); + const mockConfig = createMockConfig(tempRootDir); + + const result = await readManyFiles(mockConfig, { paths: ['mydir'] }); + + const content = contentToString(result.contentParts); + expect(content).toContain('--- Content from referenced files ---'); + expect(content).toContain('Content from'); + expect(content).toContain('mydir'); + expect(content).toContain('file1.txt'); + expect(content).toContain('file2.txt'); + // Should NOT contain the file contents, just the structure + expect(content).not.toContain('Content of file1.txt'); + }); + + it('should handle directory with trailing slash', async () => { + await createTestFile('mydir', 'file1.txt'); + const mockConfig = createMockConfig(tempRootDir); + + const result = await readManyFiles(mockConfig, { paths: ['mydir/'] }); + + const content = contentToString(result.contentParts); + expect(content).toContain('Content from'); + expect(content).toContain('mydir'); + }); + + it('should handle empty directory', async () => { + await createTestDir('emptydir'); + const mockConfig = createMockConfig(tempRootDir); + + const result = await readManyFiles(mockConfig, { paths: ['emptydir'] }); + + const content = contentToString(result.contentParts); + expect(content).toContain('Content from'); + expect(content).toContain('emptydir'); + }); + }); + + describe('mixed files and directories', () => { + it('should handle mix of files and directories', async () => { + await createTestFile('file.txt'); + await createTestFile('mydir', 'nested.txt'); + const mockConfig = createMockConfig(tempRootDir); + + const result = await readManyFiles(mockConfig, { + paths: ['file.txt', 'mydir'], + }); + + const content = contentToString(result.contentParts); + expect(content).toContain('--- Content from referenced files ---'); + // File content should be present + expect(content).toContain('Content of file.txt'); + // Directory structure should be present + expect(content).toContain('Content from'); + expect(content).toContain('mydir'); + expect(content).toContain('nested.txt'); + }); + }); + + describe('edge cases', () => { + it('should handle paths with special characters', async () => { + await createTestFile('dir-with-dash', 'file.txt'); + const mockConfig = createMockConfig(tempRootDir); + + const result = await readManyFiles(mockConfig, { + paths: ['dir-with-dash'], + }); + + const content = contentToString(result.contentParts); + expect(content).toContain('Content from'); + expect(content).toContain('dir-with-dash'); + }); + + it('should allow directories outside project root', async () => { + // Create a directory outside the workspace + const outsideDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'outside-workspace-'), + ); + await fs.writeFile(path.join(outsideDir, 'secret.txt'), 'secret'); + + const mockConfig = createMockConfig(tempRootDir); + + const result = await readManyFiles(mockConfig, { paths: [outsideDir] }); + + // Should include the outside directory listing + expect(contentToString(result.contentParts)).toContain('secret.txt'); + + // Cleanup + await fs.rm(outsideDir, { recursive: true, force: true }); + }); + }); + + describe('files array', () => { + it('should populate files array for single file', async () => { + const { absolutePath } = await createTestFile('file1.txt'); + const mockConfig = createMockConfig(tempRootDir); + + const result = await readManyFiles(mockConfig, { paths: ['file1.txt'] }); + + expect(result.files).toHaveLength(1); + expect(result.files[0].filePath).toBe(absolutePath); + expect(result.files[0].isDirectory).toBe(false); + expect(result.files[0].content).toContain('Content of file1.txt'); + }); + + it('should populate files array for multiple files', async () => { + const file1 = await createTestFile('file1.txt'); + const file2 = await createTestFile('file2.txt'); + const mockConfig = createMockConfig(tempRootDir); + + const result = await readManyFiles(mockConfig, { + paths: ['file1.txt', 'file2.txt'], + }); + + expect(result.files).toHaveLength(2); + const filePaths = result.files.map((f) => f.filePath); + expect(filePaths).toContain(file1.absolutePath); + expect(filePaths).toContain(file2.absolutePath); + }); + + it('should mark directories in files array', async () => { + await createTestFile('mydir', 'nested.txt'); + const mockConfig = createMockConfig(tempRootDir); + + const result = await readManyFiles(mockConfig, { paths: ['mydir'] }); + + expect(result.files).toHaveLength(1); + expect(result.files[0].isDirectory).toBe(true); + expect(result.files[0].filePath).toContain('mydir'); + }); + + it('should include both files and directories in files array', async () => { + const file = await createTestFile('file.txt'); + await createTestFile('mydir', 'nested.txt'); + const mockConfig = createMockConfig(tempRootDir); + + const result = await readManyFiles(mockConfig, { + paths: ['file.txt', 'mydir'], + }); + + expect(result.files).toHaveLength(2); + + const fileEntry = result.files.find((f) => !f.isDirectory); + const dirEntry = result.files.find((f) => f.isDirectory); + + expect(fileEntry).toBeDefined(); + expect(fileEntry!.filePath).toBe(file.absolutePath); + + expect(dirEntry).toBeDefined(); + expect(dirEntry!.filePath).toContain('mydir'); + }); + + it('should return empty files array when no files found', async () => { + const mockConfig = createMockConfig(tempRootDir); + + const result = await readManyFiles(mockConfig, { + paths: ['nonexistent.txt'], + }); + + expect(result.files).toHaveLength(0); + }); + + it('should return empty files array on error', async () => { + const mockConfig = { + ...createMockConfig(tempRootDir), + getProjectRoot: () => { + throw new Error('Test error'); + }, + } as unknown as Config; + + const result = await readManyFiles(mockConfig, { paths: ['file.txt'] }); + + expect(result.files).toHaveLength(0); + expect(result.error).toBeDefined(); + }); + }); +}); diff --git a/packages/core/src/utils/readManyFiles.ts b/packages/core/src/utils/readManyFiles.ts new file mode 100644 index 000000000..d3f6d8a26 --- /dev/null +++ b/packages/core/src/utils/readManyFiles.ts @@ -0,0 +1,210 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import type { Part, PartListUnion } from '@google/genai'; +import type { Config } from '../config/config.js'; +import { getErrorMessage } from './errors.js'; +import { processSingleFileContent } from './fileUtils.js'; +import { getFolderStructure } from './getFolderStructure.js'; + +/** + * Options for reading multiple files. + */ +export interface ReadManyFilesOptions { + /** + * An array of file or directory paths to read. + * Paths are relative to the project root. + */ + paths: string[]; + + /** + * Optional AbortSignal for cancellation support. + */ + signal?: AbortSignal; +} + +/** + * Information about a single file that was read. + */ +export interface FileReadInfo { + /** Absolute path to the file */ + filePath: string; + /** Content of the file (string for text, Part for images/PDFs) */ + content: PartListUnion; + /** Whether this is a directory listing rather than file content */ + isDirectory: boolean; +} + +/** + * Result from reading multiple files. + */ +export interface ReadManyFilesResult { + /** + * Content parts ready for LLM consumption. + * For text files, content is concatenated with separators. + * For images/PDFs, includes inline data parts. + */ + contentParts: PartListUnion; + + /** + * Individual file results with paths and content. + * Used for recording each file read as a separate tool result. + */ + files: FileReadInfo[]; + + /** + * Error message if an error occurred during file search. + */ + error?: string; +} + +const DEFAULT_OUTPUT_HEADER = '\n--- Content from referenced files ---'; +const DEFAULT_OUTPUT_TERMINATOR = '\n--- End of content ---'; + +/** + * Reads content from multiple files and directories specified by paths. + * + * For directories, returns the folder structure. + * For text files, concatenates their content into a single string with separators. + * For image and PDF files, returns base64-encoded data. + * + * @param config - The runtime configuration + * @param options - Options for file reading (paths, filters, signal) + * @returns Result containing content parts and processed files + * + * NOTE: This utility is invoked only by explicit user-triggered file reads. + * Do not apply workspace filters or path restrictions here. + */ +export async function readManyFiles( + config: Config, + options: ReadManyFilesOptions, +): Promise { + const { paths: inputPatterns } = options; + + const seenFiles = new Set(); + const contentParts: Part[] = []; + const files: FileReadInfo[] = []; + + try { + const projectRoot = config.getProjectRoot(); + + for (const rawPattern of inputPatterns) { + const normalizedPattern = rawPattern.replace(/\\/g, '/'); + const fullPath = path.resolve(projectRoot, normalizedPattern); + const stats = fs.existsSync(fullPath) ? fs.statSync(fullPath) : null; + + if (stats?.isDirectory()) { + const { contentParts: dirParts, info } = await readDirectory( + config, + fullPath, + ); + contentParts.push(...dirParts); + files.push(info); + continue; + } + + if (stats?.isFile() && !seenFiles.has(fullPath)) { + seenFiles.add(fullPath); + const readResult = await readFileContent(config, fullPath); + if (readResult) { + contentParts.push(...readResult.contentParts); + files.push(readResult.info); + } + } + } + } catch (error) { + const errorMessage = `Error during file search: ${getErrorMessage(error)}`; + return { + contentParts: [errorMessage], + files: [], + error: errorMessage, + }; + } + + if (contentParts.length > 0) { + contentParts.unshift({ text: DEFAULT_OUTPUT_HEADER }); + contentParts.push({ text: DEFAULT_OUTPUT_TERMINATOR }); + } else { + contentParts.push({ + text: 'No files matching the criteria were found or all were skipped.', + }); + } + + return { contentParts: contentParts as PartListUnion, files }; +} + +async function readDirectory( + config: Config, + directoryPath: string, +): Promise<{ contentParts: Part[]; info: FileReadInfo }> { + const structure = await getFolderStructure(directoryPath, { + fileService: config.getFileService(), + fileFilteringOptions: config.getFileFilteringOptions(), + }); + + const contentParts: Part[] = [ + { text: `\nContent from ${directoryPath}:\n` }, + { text: structure }, + ]; + + return { + contentParts, + info: { + filePath: directoryPath, + content: structure, + isDirectory: true, + }, + }; +} + +async function readFileContent( + config: Config, + filePath: string, +): Promise<{ contentParts: Part[]; info: FileReadInfo } | null> { + try { + const fileReadResult = await processSingleFileContent(filePath, config); + if (fileReadResult.error) { + return null; + } + + const prefixText: Part = { text: `\nContent from ${filePath}:\n` }; + + if (typeof fileReadResult.llmContent === 'string') { + let fileContentForLlm = ''; + if (fileReadResult.isTruncated) { + const [start, end] = fileReadResult.linesShown!; + const total = fileReadResult.originalLineCount!; + fileContentForLlm = `Showing lines ${start}-${end} of ${total} total lines.\n---\n${fileReadResult.llmContent}`; + } else { + fileContentForLlm = fileReadResult.llmContent; + } + const contentParts: Part[] = [prefixText, { text: fileContentForLlm }]; + return { + contentParts, + info: { + filePath, + content: fileContentForLlm, + isDirectory: false, + }, + }; + } + + // For binary files (images, PDFs), add prefix text before the inlineData/fileData part + const contentParts: Part[] = [prefixText, fileReadResult.llmContent]; + return { + contentParts, + info: { + filePath, + content: fileReadResult.llmContent, + isDirectory: false, + }, + }; + } catch { + return null; + } +} From 37ad24b0d949af01ca5203ec83b06b29f8f9da01 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Sun, 1 Feb 2026 14:39:54 +0800 Subject: [PATCH 06/49] feat: Remove Smart Edit tool and ClearcutLogger - Remove SmartEditTool and its associated LLM edit fixer utilities - Remove ClearcutLogger and related telemetry infrastructure - Remove useSmartEdit configuration option from settings schema - Add deprecation warning for users with existing useSmartEdit config - Clean up all related tests and mocks The Smart Edit tool was designed to provide flexible matching and LLM-based correction for edit operations. However, current models are now capable of effectively using the standard Edit tool without these optimizations, and the Smart Edit tool lacked comprehensive testing coverage. Co-authored-by: Qwen-Coder --- packages/cli/src/config/config.ts | 2 - packages/cli/src/config/settingsSchema.ts | 9 - packages/cli/src/gemini.test.tsx | 1 - .../src/ui/components/ModelDialog.test.tsx | 2 - .../cli/src/ui/hooks/useGeminiStream.test.tsx | 1 - .../cli/src/ui/hooks/useToolScheduler.test.ts | 1 - packages/core/src/config/config.ts | 14 +- packages/core/src/core/client.test.ts | 1 - .../core/src/core/coreToolScheduler.test.ts | 17 - .../core/nonInteractiveToolExecutor.test.ts | 1 - packages/core/src/index.ts | 6 +- .../clearcut-logger/clearcut-logger.test.ts | 752 ----------- .../clearcut-logger/clearcut-logger.ts | 1130 ----------------- .../clearcut-logger/event-metadata-key.ts | 419 ------ packages/core/src/tools/smart-edit.test.ts | 674 ---------- packages/core/src/tools/smart-edit.ts | 965 -------------- .../core/src/utils/llm-edit-fixer.test.ts | 322 ----- packages/core/src/utils/llm-edit-fixer.ts | 164 --- 18 files changed, 6 insertions(+), 4475 deletions(-) delete mode 100644 packages/core/src/telemetry/clearcut-logger/clearcut-logger.test.ts delete mode 100644 packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts delete mode 100644 packages/core/src/telemetry/clearcut-logger/event-metadata-key.ts delete mode 100644 packages/core/src/tools/smart-edit.test.ts delete mode 100644 packages/core/src/tools/smart-edit.ts delete mode 100644 packages/core/src/utils/llm-edit-fixer.test.ts delete mode 100644 packages/core/src/utils/llm-edit-fixer.ts diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index d4752d4be..876e20e7c 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -132,7 +132,6 @@ export interface CliArgs { webSearchDefault: string | undefined; screenReader: boolean | undefined; vlmSwitchMode: string | undefined; - useSmartEdit: boolean | undefined; inputFormat?: string | undefined; outputFormat: string | undefined; includePartialMessages?: boolean; @@ -1019,7 +1018,6 @@ export async function loadCliConfig( truncateToolOutputLines: settings.tools?.truncateToolOutputLines, enableToolOutputTruncation: settings.tools?.enableToolOutputTruncation, eventEmitter: appEvents, - useSmartEdit: argv.useSmartEdit ?? settings.useSmartEdit, gitCoAuthor: settings.general?.gitCoAuthor, output: { format: outputSettingsFormat, diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 44340b81e..18982ec81 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -968,15 +968,6 @@ const SETTINGS_SCHEMA = { }, }, }, - useSmartEdit: { - type: 'boolean', - label: 'Use Smart Edit', - category: 'Advanced', - requiresRestart: false, - default: false, - description: 'Enable the smart-edit tool instead of the replace tool.', - showInDialog: false, - }, security: { type: 'object', label: 'Security', diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 25db908c4..270798398 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -478,7 +478,6 @@ describe('gemini.tsx main function kitty protocol', () => { webSearchDefault: undefined, screenReader: undefined, vlmSwitchMode: undefined, - useSmartEdit: undefined, inputFormat: undefined, outputFormat: undefined, includePartialMessages: undefined, diff --git a/packages/cli/src/ui/components/ModelDialog.test.tsx b/packages/cli/src/ui/components/ModelDialog.test.tsx index 98da6031f..8bff7a2a2 100644 --- a/packages/cli/src/ui/components/ModelDialog.test.tsx +++ b/packages/cli/src/ui/components/ModelDialog.test.tsx @@ -55,7 +55,6 @@ const renderComponent = ( switchModel: vi.fn().mockResolvedValue(undefined), getAuthType: vi.fn(() => 'qwen-oauth'), - // --- Functions used by ClearcutLogger --- getUsageStatisticsEnabled: vi.fn(() => true), getSessionId: vi.fn(() => 'mock-session-id'), getDebugMode: vi.fn(() => false), @@ -63,7 +62,6 @@ const renderComponent = ( authType: AuthType.QWEN_OAUTH, model: MAINLINE_CODER, })), - getUseSmartEdit: vi.fn(() => false), getUseModelRouter: vi.fn(() => false), getProxy: vi.fn(() => undefined), diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index d9aafa2e2..7ebfc2200 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -233,7 +233,6 @@ describe('useGeminiStream', () => { .fn() .mockReturnValue(contentGeneratorConfig), getMaxSessionTurns: vi.fn(() => 50), - getUseSmartEdit: () => false, } as unknown as Config; mockOnDebugMessage = vi.fn(); mockHandleSlashCommand = vi.fn().mockResolvedValue(false); diff --git a/packages/cli/src/ui/hooks/useToolScheduler.test.ts b/packages/cli/src/ui/hooks/useToolScheduler.test.ts index 961b52b24..4e0b753d3 100644 --- a/packages/cli/src/ui/hooks/useToolScheduler.test.ts +++ b/packages/cli/src/ui/hooks/useToolScheduler.test.ts @@ -64,7 +64,6 @@ const mockConfig = { model: 'test-model', authType: 'gemini', }), - getUseSmartEdit: () => false, getUseModelRouter: () => false, getGeminiClient: () => null, // No client needed for these tests getShellExecutionConfig: () => ({ terminalWidth: 80, terminalHeight: 24 }), diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index af2d28555..99ebda278 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -52,7 +52,6 @@ import { ReadManyFilesTool } from '../tools/read-many-files.js'; import { canUseRipgrep } from '../utils/ripgrepUtils.js'; import { RipGrepTool } from '../tools/ripGrep.js'; import { ShellTool } from '../tools/shell.js'; -import { SmartEditTool } from '../tools/smart-edit.js'; import { SkillTool } from '../tools/skill.js'; import { TaskTool } from '../tools/task.js'; import { TodoWriteTool } from '../tools/todoWrite.js'; @@ -361,7 +360,6 @@ export interface ConfigParameters { truncateToolOutputLines?: number; enableToolOutputTruncation?: boolean; eventEmitter?: EventEmitter; - useSmartEdit?: boolean; output?: OutputSettings; inputFormat?: InputFormat; outputFormat?: OutputFormat; @@ -510,7 +508,6 @@ export class Config { private readonly truncateToolOutputLines: number; private readonly enableToolOutputTruncation: boolean; private readonly eventEmitter?: EventEmitter; - private readonly useSmartEdit: boolean; private readonly channel: string | undefined; constructor(params: ConfigParameters) { @@ -623,7 +620,6 @@ export class Config { this.truncateToolOutputLines = params.truncateToolOutputLines ?? DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES; this.enableToolOutputTruncation = params.enableToolOutputTruncation ?? true; - this.useSmartEdit = params.useSmartEdit ?? false; this.channel = params.channel; this.storage = new Storage(this.targetDir); this.vlmSwitchMode = params.vlmSwitchMode; @@ -1523,10 +1519,6 @@ export class Config { return this.truncateToolOutputLines; } - getUseSmartEdit(): boolean { - return this.useSmartEdit; - } - getOutputFormat(): OutputFormat { return this.outputFormat; } @@ -1649,11 +1641,7 @@ export class Config { } registerCoreTool(GlobTool, this); - if (this.getUseSmartEdit()) { - registerCoreTool(SmartEditTool, this); - } else { - registerCoreTool(EditTool, this); - } + registerCoreTool(EditTool, this); registerCoreTool(WriteFileTool, this); registerCoreTool(ReadManyFilesTool, this); registerCoreTool(ShellTool, this); diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index 25f47f1e2..b5234045e 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -337,7 +337,6 @@ describe('Gemini Client (client.ts)', () => { getCliVersion: vi.fn().mockReturnValue('1.0.0'), getChatCompression: vi.fn().mockReturnValue(undefined), getSkipNextSpeakerCheck: vi.fn().mockReturnValue(false), - getUseSmartEdit: vi.fn().mockReturnValue(false), getUseModelRouter: vi.fn().mockReturnValue(false), getProjectRoot: vi.fn().mockReturnValue('/test/project/root'), storage: { diff --git a/packages/core/src/core/coreToolScheduler.test.ts b/packages/core/src/core/coreToolScheduler.test.ts index fff05f3b9..1c0c5adf6 100644 --- a/packages/core/src/core/coreToolScheduler.test.ts +++ b/packages/core/src/core/coreToolScheduler.test.ts @@ -254,7 +254,6 @@ describe('CoreToolScheduler', () => { DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, getToolRegistry: () => mockToolRegistry, - getUseSmartEdit: () => false, getUseModelRouter: () => false, getGeminiClient: () => null, // No client needed for these tests getChatRecordingService: () => undefined, @@ -332,7 +331,6 @@ describe('CoreToolScheduler', () => { DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, getToolRegistry: () => mockToolRegistry, - getUseSmartEdit: () => false, getUseModelRouter: () => false, getGeminiClient: () => null, getChatRecordingService: () => undefined, @@ -375,7 +373,6 @@ describe('CoreToolScheduler', () => { } as unknown as ToolRegistry; const mockConfig = { getToolRegistry: () => mockToolRegistry, - getUseSmartEdit: () => false, getUseModelRouter: () => false, getGeminiClient: () => null, // No client needed for these tests getExcludeTools: () => undefined, @@ -417,7 +414,6 @@ describe('CoreToolScheduler', () => { // Create mocked config with excluded tools const mockConfig = { getToolRegistry: () => mockToolRegistry, - getUseSmartEdit: () => false, getUseModelRouter: () => false, getGeminiClient: () => null, getExcludeTools: () => ['write_file', 'edit', 'run_shell_command'], @@ -448,7 +444,6 @@ describe('CoreToolScheduler', () => { // Create mocked config with excluded tools const mockConfig = { getToolRegistry: () => mockToolRegistry, - getUseSmartEdit: () => false, getUseModelRouter: () => false, getGeminiClient: () => null, getExcludeTools: () => ['write_file', 'edit'], @@ -490,7 +485,6 @@ describe('CoreToolScheduler', () => { // Create mocked config const mockConfig = { getToolRegistry: () => mockToolRegistry, - getUseSmartEdit: () => false, getUseModelRouter: () => false, getGeminiClient: () => null, getExcludeTools: () => undefined, @@ -570,7 +564,6 @@ describe('CoreToolScheduler', () => { DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, getToolRegistry: () => mockToolRegistry, - getUseSmartEdit: () => false, getUseModelRouter: () => false, getGeminiClient: () => null, getChatRecordingService: () => undefined, @@ -657,7 +650,6 @@ describe('CoreToolScheduler', () => { DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, getToolRegistry: () => mockToolRegistry, - getUseSmartEdit: () => false, getUseModelRouter: () => false, getGeminiClient: () => null, getChatRecordingService: () => undefined, @@ -747,7 +739,6 @@ describe('CoreToolScheduler with payload', () => { DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, getToolRegistry: () => mockToolRegistry, - getUseSmartEdit: () => false, getUseModelRouter: () => false, getGeminiClient: () => null, // No client needed for these tests isInteractive: () => true, // Required to prevent auto-denial of tool calls @@ -1077,7 +1068,6 @@ describe('CoreToolScheduler edit cancellation', () => { getProjectTempDir: () => '/tmp', }, getToolRegistry: () => mockToolRegistry, - getUseSmartEdit: () => false, getUseModelRouter: () => false, getGeminiClient: () => null, // No client needed for these tests isInteractive: () => true, // Required to prevent auto-denial of tool calls @@ -1187,7 +1177,6 @@ describe('CoreToolScheduler YOLO mode', () => { getTruncateToolOutputThreshold: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, - getUseSmartEdit: () => false, getUseModelRouter: () => false, getGeminiClient: () => null, // No client needed for these tests getChatRecordingService: () => undefined, @@ -1429,7 +1418,6 @@ describe('CoreToolScheduler request queueing', () => { DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, getToolRegistry: () => mockToolRegistry, - getUseSmartEdit: () => false, getUseModelRouter: () => false, getGeminiClient: () => null, // No client needed for these tests getChatRecordingService: () => undefined, @@ -1562,7 +1550,6 @@ describe('CoreToolScheduler request queueing', () => { getTruncateToolOutputThreshold: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, - getUseSmartEdit: () => false, getUseModelRouter: () => false, getGeminiClient: () => null, // No client needed for these tests getChatRecordingService: () => undefined, @@ -1665,7 +1652,6 @@ describe('CoreToolScheduler request queueing', () => { DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, getToolRegistry: () => mockToolRegistry, - getUseSmartEdit: () => false, getUseModelRouter: () => false, getGeminiClient: () => null, // No client needed for these tests getChatRecordingService: () => undefined, @@ -1738,7 +1724,6 @@ describe('CoreToolScheduler request queueing', () => { getTruncateToolOutputThreshold: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, - getUseSmartEdit: () => false, getUseModelRouter: () => false, getGeminiClient: () => null, // No client needed for these tests isInteractive: () => true, // Required to prevent auto-denial of tool calls @@ -1933,7 +1918,6 @@ describe('CoreToolScheduler Sequential Execution', () => { getTruncateToolOutputThreshold: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, - getUseSmartEdit: () => false, getUseModelRouter: () => false, getGeminiClient: () => null, getChatRecordingService: () => undefined, @@ -2054,7 +2038,6 @@ describe('CoreToolScheduler Sequential Execution', () => { getTruncateToolOutputThreshold: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, - getUseSmartEdit: () => false, getUseModelRouter: () => false, getGeminiClient: () => null, getChatRecordingService: () => undefined, diff --git a/packages/core/src/core/nonInteractiveToolExecutor.test.ts b/packages/core/src/core/nonInteractiveToolExecutor.test.ts index cbc4c145a..989b61c37 100644 --- a/packages/core/src/core/nonInteractiveToolExecutor.test.ts +++ b/packages/core/src/core/nonInteractiveToolExecutor.test.ts @@ -59,7 +59,6 @@ describe('executeToolCall', () => { getTruncateToolOutputThreshold: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, - getUseSmartEdit: () => false, getUseModelRouter: () => false, getGeminiClient: () => null, // No client needed for these tests getChatRecordingService: () => undefined, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a9c091a08..b9016efcc 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -166,7 +166,11 @@ export type { } from './mcp/oauth-utils.js'; export { OAuthUtils } from './mcp/oauth-utils.js'; -// Export telemetry functions +// ============================================================================ +// Telemetry +// ============================================================================ + +export { QwenLogger } from './telemetry/qwen-logger/qwen-logger.js'; export * from './telemetry/index.js'; export * from './utils/browser.js'; // OpenAI Logging Utilities diff --git a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.test.ts b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.test.ts deleted file mode 100644 index 4dd037205..000000000 --- a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.test.ts +++ /dev/null @@ -1,752 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import 'vitest'; -import { - vi, - describe, - it, - expect, - afterEach, - beforeAll, - afterAll, -} from 'vitest'; -import type { LogEvent, LogEventEntry } from './clearcut-logger.js'; -import { ClearcutLogger, EventNames, TEST_ONLY } from './clearcut-logger.js'; -import type { ContentGeneratorConfig } from '../../core/contentGenerator.js'; -import { AuthType } from '../../core/contentGenerator.js'; -import type { SuccessfulToolCall } from '../../core/coreToolScheduler.js'; -import type { ConfigParameters } from '../../config/config.js'; -import { EventMetadataKey } from './event-metadata-key.js'; -import { makeFakeConfig } from '../../test-utils/config.js'; -import { http, HttpResponse } from 'msw'; -import { server } from '../../mocks/msw.js'; -import { - UserPromptEvent, - makeChatCompressionEvent, - ToolCallEvent, -} from '../types.js'; -import { GIT_COMMIT_INFO, CLI_VERSION } from '../../generated/git-commit.js'; -import { InstallationManager } from '../../utils/installationManager.js'; -import { safeJsonStringify } from '../../utils/safeJsonStringify.js'; - -interface CustomMatchers { - toHaveMetadataValue: ([key, value]: [EventMetadataKey, string]) => R; - toHaveEventName: (name: EventNames) => R; - toHaveMetadataKey: (key: EventMetadataKey) => R; -} - -declare module 'vitest' { - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-empty-object-type - interface Matchers extends CustomMatchers {} -} - -expect.extend({ - toHaveEventName(received: LogEventEntry[], name: EventNames) { - const { isNot } = this; - const event = JSON.parse(received[0].source_extension_json) as LogEvent; - const pass = event.event_name === (name as unknown as string); - return { - pass, - message: () => - `event name ${event.event_name} does${isNot ? ' not ' : ''} match ${name}}`, - }; - }, - - toHaveMetadataValue( - received: LogEventEntry[], - [key, value]: [EventMetadataKey, string], - ) { - const { isNot } = this; - const event = JSON.parse(received[0].source_extension_json) as LogEvent; - const metadata = event['event_metadata'][0]; - const data = metadata.find((m) => m.gemini_cli_key === key)?.value; - - const pass = data !== undefined && data === value; - - return { - pass, - message: () => - `event ${received} does${isNot ? ' not' : ''} have ${value}}`, - }; - }, - - toHaveMetadataKey(received: LogEventEntry[], key: EventMetadataKey) { - const { isNot } = this; - const event = JSON.parse(received[0].source_extension_json) as LogEvent; - const metadata = event['event_metadata'][0]; - - const pass = metadata.some((m) => m.gemini_cli_key === key); - - return { - pass, - message: () => - `event ${received} ${isNot ? 'has' : 'does not have'} the metadata key ${key}`, - }; - }, -}); - -vi.mock('../../utils/installationManager.js'); - -const mockInstallMgr = vi.mocked(InstallationManager.prototype); - -// TODO(richieforeman): Consider moving this to test setup globally. -beforeAll(() => { - server.listen({}); -}); - -afterEach(() => { - server.resetHandlers(); -}); - -afterAll(() => { - server.close(); -}); - -describe('ClearcutLogger', () => { - const NEXT_WAIT_MS = 1234; - const CLEARCUT_URL = 'https://play.googleapis.com/log'; - const MOCK_DATE = new Date('2025-01-02T00:00:00.000Z'); - const EXAMPLE_RESPONSE = `["${NEXT_WAIT_MS}",null,[[["ANDROID_BACKUP",0],["BATTERY_STATS",0],["SMART_SETUP",0],["TRON",0]],-3334737594024971225],[]]`; - - // A helper to get the internal events array for testing - const getEvents = (l: ClearcutLogger): LogEventEntry[][] => - l['events'].toArray() as LogEventEntry[][]; - - const getEventsSize = (l: ClearcutLogger): number => l['events'].size; - - const requeueFailedEvents = (l: ClearcutLogger, events: LogEventEntry[][]) => - l['requeueFailedEvents'](events); - - afterEach(() => { - vi.unstubAllEnvs(); - }); - - function setup({ config = {} as Partial } = {}) { - server.resetHandlers( - http.post(CLEARCUT_URL, () => HttpResponse.text(EXAMPLE_RESPONSE)), - ); - - vi.useFakeTimers(); - vi.setSystemTime(MOCK_DATE); - - const loggerConfig = makeFakeConfig({ - ...config, - sessionId: 'test-session-id', - }); - ClearcutLogger.clearInstance(); - - mockInstallMgr.getInstallationId = vi - .fn() - .mockReturnValue('test-installation-id'); - - const logger = ClearcutLogger.getInstance(loggerConfig); - - return { logger, loggerConfig }; - } - - afterEach(() => { - ClearcutLogger.clearInstance(); - vi.useRealTimers(); - vi.restoreAllMocks(); - }); - - describe('getInstance', () => { - it.each([ - { usageStatisticsEnabled: false, expectedValue: undefined }, - { - usageStatisticsEnabled: true, - expectedValue: expect.any(ClearcutLogger), - }, - ])( - 'returns an instance if usage statistics are enabled', - ({ usageStatisticsEnabled, expectedValue }) => { - ClearcutLogger.clearInstance(); - const { logger } = setup({ - config: { - usageStatisticsEnabled, - }, - }); - expect(logger).toEqual(expectedValue); - }, - ); - - it('is a singleton', () => { - ClearcutLogger.clearInstance(); - const { loggerConfig } = setup(); - const logger1 = ClearcutLogger.getInstance(loggerConfig); - const logger2 = ClearcutLogger.getInstance(loggerConfig); - expect(logger1).toBe(logger2); - }); - }); - - describe('createLogEvent', () => { - it('logs the current surface from a github action', () => { - const { logger } = setup({}); - - vi.stubEnv('GITHUB_SHA', '8675309'); - - const event = logger?.createLogEvent(EventNames.CHAT_COMPRESSION, []); - - expect(event?.event_metadata[0]).toContainEqual({ - gemini_cli_key: EventMetadataKey.GEMINI_CLI_SURFACE, - value: 'GitHub', - }); - }); - - it('logs the current surface from Cloud Shell via EDITOR_IN_CLOUD_SHELL', () => { - const { logger } = setup({}); - - vi.stubEnv('EDITOR_IN_CLOUD_SHELL', 'true'); - - const event = logger?.createLogEvent(EventNames.CHAT_COMPRESSION, []); - - expect(event?.event_metadata[0]).toContainEqual({ - gemini_cli_key: EventMetadataKey.GEMINI_CLI_SURFACE, - value: 'cloudshell', - }); - }); - - it('logs the current surface from Cloud Shell via CLOUD_SHELL', () => { - const { logger } = setup({}); - - vi.stubEnv('CLOUD_SHELL', 'true'); - - const event = logger?.createLogEvent(EventNames.CHAT_COMPRESSION, []); - - expect(event?.event_metadata[0]).toContainEqual({ - gemini_cli_key: EventMetadataKey.GEMINI_CLI_SURFACE, - value: 'cloudshell', - }); - }); - - it('logs default metadata', () => { - // Define expected values - const session_id = 'test-session-id'; - const auth_type = AuthType.USE_GEMINI; - const surface = 'ide-1234'; - const cli_version = CLI_VERSION; - const git_commit_hash = GIT_COMMIT_INFO; - const prompt_id = 'my-prompt-123'; - const user_settings = safeJsonStringify([{ smart_edit_enabled: false }]); - - // Setup logger with expected values - const { logger, loggerConfig } = setup({ - config: {}, - }); - vi.spyOn(loggerConfig, 'getContentGeneratorConfig').mockReturnValue({ - authType: auth_type, - } as ContentGeneratorConfig); - logger?.logNewPromptEvent(new UserPromptEvent(1, prompt_id)); // prompt_id == session_id before this - vi.stubEnv('SURFACE', surface); - - // Create log event - const event = logger?.createLogEvent(EventNames.API_ERROR, []); - - // Ensure expected values exist - expect(event?.event_metadata[0]).toEqual( - expect.arrayContaining([ - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_SESSION_ID, - value: session_id, - }, - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_AUTH_TYPE, - value: JSON.stringify(auth_type), - }, - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_SURFACE, - value: surface, - }, - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_VERSION, - value: cli_version, - }, - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_GIT_COMMIT_HASH, - value: git_commit_hash, - }, - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_PROMPT_ID, - value: prompt_id, - }, - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_OS, - value: process.platform, - }, - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_USER_SETTINGS, - value: user_settings, - }, - ]), - ); - }); - - it('logs the current nodejs version', () => { - const { logger } = setup({}); - - const event = logger?.createLogEvent(EventNames.API_ERROR, []); - - expect(event?.event_metadata[0]).toContainEqual({ - gemini_cli_key: EventMetadataKey.GEMINI_CLI_NODE_VERSION, - value: process.versions.node, - }); - }); - - it('logs the current surface', () => { - const { logger } = setup({}); - - vi.stubEnv('TERM_PROGRAM', 'vscode'); - vi.stubEnv('SURFACE', 'ide-1234'); - - const event = logger?.createLogEvent(EventNames.API_ERROR, []); - - expect(event?.event_metadata[0]).toContainEqual({ - gemini_cli_key: EventMetadataKey.GEMINI_CLI_SURFACE, - value: 'ide-1234', - }); - }); - - it.each([ - { - env: { - CURSOR_TRACE_ID: 'abc123', - GITHUB_SHA: undefined, - TERM_PROGRAM: 'vscode', - }, - expectedValue: 'cursor', - }, - { - env: { - TERM_PROGRAM: 'vscode', - GITHUB_SHA: undefined, - MONOSPACE_ENV: '', - }, - expectedValue: 'vscode', - }, - { - env: { - MONOSPACE_ENV: 'true', - GITHUB_SHA: undefined, - TERM_PROGRAM: 'vscode', - }, - expectedValue: 'firebasestudio', - }, - { - env: { - __COG_BASHRC_SOURCED: 'true', - GITHUB_SHA: undefined, - TERM_PROGRAM: 'vscode', - }, - expectedValue: 'devin', - }, - { - env: { - CLOUD_SHELL: 'true', - GITHUB_SHA: undefined, - TERM_PROGRAM: 'vscode', - }, - expectedValue: 'cloudshell', - }, - ])( - 'logs the current surface as $expectedValue, preempting vscode detection', - ({ env, expectedValue }) => { - const { logger } = setup({}); - - // Clear all environment variables that could interfere with surface detection - vi.stubEnv('SURFACE', undefined); - vi.stubEnv('GITHUB_SHA', undefined); - vi.stubEnv('CURSOR_TRACE_ID', undefined); - vi.stubEnv('__COG_BASHRC_SOURCED', undefined); - vi.stubEnv('REPLIT_USER', undefined); - vi.stubEnv('CODESPACES', undefined); - vi.stubEnv('EDITOR_IN_CLOUD_SHELL', undefined); - vi.stubEnv('CLOUD_SHELL', undefined); - vi.stubEnv('TERM_PRODUCT', undefined); - vi.stubEnv('FIREBASE_DEPLOY_AGENT', undefined); - vi.stubEnv('MONOSPACE_ENV', undefined); - - // Set the specific environment variables for this test case - for (const [key, value] of Object.entries(env)) { - vi.stubEnv(key, value); - } - const event = logger?.createLogEvent(EventNames.API_ERROR, []); - expect(event?.event_metadata[0]).toEqual( - expect.arrayContaining([ - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_SURFACE, - value: expectedValue, - }, - ]), - ); - }, - ); - }); - - describe('logChatCompressionEvent', () => { - it('logs an event with proper fields', () => { - const { logger } = setup(); - logger?.logChatCompressionEvent( - makeChatCompressionEvent({ - tokens_before: 9001, - tokens_after: 8000, - }), - ); - - const events = getEvents(logger!); - expect(events.length).toBe(1); - expect(events[0]).toHaveEventName(EventNames.CHAT_COMPRESSION); - expect(events[0]).toHaveMetadataValue([ - EventMetadataKey.GEMINI_CLI_COMPRESSION_TOKENS_BEFORE, - '9001', - ]); - expect(events[0]).toHaveMetadataValue([ - EventMetadataKey.GEMINI_CLI_COMPRESSION_TOKENS_AFTER, - '8000', - ]); - }); - }); - - describe('logRipgrepFallbackEvent', () => { - it('logs an event with the proper name', () => { - const { logger } = setup(); - // Spy on flushToClearcut to prevent it from clearing the queue - const flushSpy = vi - // eslint-disable-next-line @typescript-eslint/no-explicit-any - .spyOn(logger!, 'flushToClearcut' as any) - .mockResolvedValue({ nextRequestWaitMs: 0 }); - - logger?.logRipgrepFallbackEvent(); - - const events = getEvents(logger!); - expect(events.length).toBe(1); - expect(events[0]).toHaveEventName(EventNames.RIPGREP_FALLBACK); - expect(flushSpy).toHaveBeenCalledOnce(); - }); - }); - - describe('enqueueLogEvent', () => { - it('should add events to the queue', () => { - const { logger } = setup(); - logger!.enqueueLogEvent(logger!.createLogEvent(EventNames.API_ERROR)); - expect(getEventsSize(logger!)).toBe(1); - }); - - it('should evict the oldest event when the queue is full', () => { - const { logger } = setup(); - - for (let i = 0; i < TEST_ONLY.MAX_EVENTS; i++) { - logger!.enqueueLogEvent( - logger!.createLogEvent(EventNames.API_ERROR, [ - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_AI_ADDED_LINES, - value: `${i}`, - }, - ]), - ); - } - - let events = getEvents(logger!); - expect(events.length).toBe(TEST_ONLY.MAX_EVENTS); - expect(events[0]).toHaveMetadataValue([ - EventMetadataKey.GEMINI_CLI_AI_ADDED_LINES, - '0', - ]); - - // This should push out the first event - logger!.enqueueLogEvent( - logger!.createLogEvent(EventNames.API_ERROR, [ - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_AI_ADDED_LINES, - value: `${TEST_ONLY.MAX_EVENTS}`, - }, - ]), - ); - events = getEvents(logger!); - expect(events.length).toBe(TEST_ONLY.MAX_EVENTS); - expect(events[0]).toHaveMetadataValue([ - EventMetadataKey.GEMINI_CLI_AI_ADDED_LINES, - '1', - ]); - - expect(events.at(TEST_ONLY.MAX_EVENTS - 1)).toHaveMetadataValue([ - EventMetadataKey.GEMINI_CLI_AI_ADDED_LINES, - `${TEST_ONLY.MAX_EVENTS}`, - ]); - }); - }); - - describe('flushToClearcut', () => { - it('allows for usage with a configured proxy agent', async () => { - const { logger } = setup({ - config: { - proxy: 'http://mycoolproxy.whatever.com:3128', - }, - }); - - logger!.enqueueLogEvent(logger!.createLogEvent(EventNames.API_ERROR)); - - const response = await logger!.flushToClearcut(); - - expect(response.nextRequestWaitMs).toBe(NEXT_WAIT_MS); - }); - - it('should clear events on successful flush', async () => { - const { logger } = setup(); - - logger!.enqueueLogEvent(logger!.createLogEvent(EventNames.API_ERROR)); - const response = await logger!.flushToClearcut(); - - expect(getEvents(logger!)).toEqual([]); - expect(response.nextRequestWaitMs).toBe(NEXT_WAIT_MS); - }); - - it('should handle a network error and requeue events', async () => { - const { logger } = setup(); - - server.resetHandlers(http.post(CLEARCUT_URL, () => HttpResponse.error())); - logger!.enqueueLogEvent(logger!.createLogEvent(EventNames.API_REQUEST)); - logger!.enqueueLogEvent(logger!.createLogEvent(EventNames.API_ERROR)); - expect(getEventsSize(logger!)).toBe(2); - - const x = logger!.flushToClearcut(); - await x; - - expect(getEventsSize(logger!)).toBe(2); - const events = getEvents(logger!); - - expect(events.length).toBe(2); - expect(events[0]).toHaveEventName(EventNames.API_REQUEST); - }); - - it('should handle an HTTP error and requeue events', async () => { - const { logger } = setup(); - - server.resetHandlers( - http.post( - CLEARCUT_URL, - () => - new HttpResponse( - { 'the system is down': true }, - { - status: 500, - }, - ), - ), - ); - - logger!.enqueueLogEvent(logger!.createLogEvent(EventNames.API_REQUEST)); - logger!.enqueueLogEvent(logger!.createLogEvent(EventNames.API_ERROR)); - - expect(getEvents(logger!).length).toBe(2); - await logger!.flushToClearcut(); - - const events = getEvents(logger!); - - expect(events[0]).toHaveEventName(EventNames.API_REQUEST); - }); - }); - - describe('requeueFailedEvents logic', () => { - it('should limit the number of requeued events to max_retry_events', () => { - const { logger } = setup(); - const eventsToLogCount = TEST_ONLY.MAX_RETRY_EVENTS + 5; - const eventsToSend: LogEventEntry[][] = []; - for (let i = 0; i < eventsToLogCount; i++) { - eventsToSend.push([ - { - event_time_ms: Date.now(), - source_extension_json: JSON.stringify({ event_id: i }), - }, - ]); - } - - requeueFailedEvents(logger!, eventsToSend); - - expect(getEventsSize(logger!)).toBe(TEST_ONLY.MAX_RETRY_EVENTS); - const firstRequeuedEvent = JSON.parse( - getEvents(logger!)[0][0].source_extension_json, - ) as { event_id: string }; - // The last `maxRetryEvents` are kept. The oldest of those is at index `eventsToLogCount - maxRetryEvents`. - expect(firstRequeuedEvent.event_id).toBe( - eventsToLogCount - TEST_ONLY.MAX_RETRY_EVENTS, - ); - }); - - it('should not requeue more events than available space in the queue', () => { - const { logger } = setup(); - const maxEvents = TEST_ONLY.MAX_EVENTS; - const spaceToLeave = 5; - const initialEventCount = maxEvents - spaceToLeave; - for (let i = 0; i < initialEventCount; i++) { - logger!.enqueueLogEvent(logger!.createLogEvent(EventNames.API_ERROR)); - } - expect(getEventsSize(logger!)).toBe(initialEventCount); - - const failedEventsCount = 10; // More than spaceToLeave - const eventsToSend: LogEventEntry[][] = []; - for (let i = 0; i < failedEventsCount; i++) { - eventsToSend.push([ - { - event_time_ms: Date.now(), - source_extension_json: JSON.stringify({ event_id: `failed_${i}` }), - }, - ]); - } - - requeueFailedEvents(logger!, eventsToSend); - - // availableSpace is 5. eventsToRequeue is min(10, 5) = 5. - // Total size should be initialEventCount + 5 = maxEvents. - expect(getEventsSize(logger!)).toBe(maxEvents); - - // The requeued events are the *last* 5 of the failed events. - // startIndex = max(0, 10 - 5) = 5. - // Loop unshifts events from index 9 down to 5. - // The first element in the deque is the one with id 'failed_5'. - const firstRequeuedEvent = JSON.parse( - getEvents(logger!)[0][0].source_extension_json, - ) as { event_id: string }; - expect(firstRequeuedEvent.event_id).toBe('failed_5'); - }); - }); - - describe('logToolCallEvent', () => { - it('logs an event with all diff metadata', () => { - const { logger } = setup(); - const completedToolCall = { - request: { name: 'test', args: {}, prompt_id: 'prompt-123' }, - response: { - resultDisplay: { - diffStat: { - model_added_lines: 1, - model_removed_lines: 2, - model_added_chars: 3, - model_removed_chars: 4, - user_added_lines: 5, - user_removed_lines: 6, - user_added_chars: 7, - user_removed_chars: 8, - }, - }, - }, - status: 'success', - } as SuccessfulToolCall; - - logger?.logToolCallEvent(new ToolCallEvent(completedToolCall)); - - const events = getEvents(logger!); - expect(events.length).toBe(1); - expect(events[0]).toHaveEventName(EventNames.TOOL_CALL); - expect(events[0]).toHaveMetadataValue([ - EventMetadataKey.GEMINI_CLI_AI_ADDED_LINES, - '1', - ]); - expect(events[0]).toHaveMetadataValue([ - EventMetadataKey.GEMINI_CLI_AI_REMOVED_LINES, - '2', - ]); - expect(events[0]).toHaveMetadataValue([ - EventMetadataKey.GEMINI_CLI_AI_ADDED_CHARS, - '3', - ]); - expect(events[0]).toHaveMetadataValue([ - EventMetadataKey.GEMINI_CLI_AI_REMOVED_CHARS, - '4', - ]); - expect(events[0]).toHaveMetadataValue([ - EventMetadataKey.GEMINI_CLI_USER_ADDED_LINES, - '5', - ]); - expect(events[0]).toHaveMetadataValue([ - EventMetadataKey.GEMINI_CLI_USER_REMOVED_LINES, - '6', - ]); - expect(events[0]).toHaveMetadataValue([ - EventMetadataKey.GEMINI_CLI_USER_ADDED_CHARS, - '7', - ]); - expect(events[0]).toHaveMetadataValue([ - EventMetadataKey.GEMINI_CLI_USER_REMOVED_CHARS, - '8', - ]); - }); - - it('logs an event with partial diff metadata', () => { - const { logger } = setup(); - const completedToolCall = { - request: { name: 'test', args: {}, prompt_id: 'prompt-123' }, - response: { - resultDisplay: { - diffStat: { - model_added_lines: 1, - model_removed_lines: 2, - model_added_chars: 3, - model_removed_chars: 4, - }, - }, - }, - status: 'success', - } as SuccessfulToolCall; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - logger?.logToolCallEvent(new ToolCallEvent(completedToolCall as any)); - - const events = getEvents(logger!); - expect(events.length).toBe(1); - expect(events[0]).toHaveEventName(EventNames.TOOL_CALL); - expect(events[0]).toHaveMetadataValue([ - EventMetadataKey.GEMINI_CLI_AI_ADDED_LINES, - '1', - ]); - expect(events[0]).toHaveMetadataValue([ - EventMetadataKey.GEMINI_CLI_AI_REMOVED_LINES, - '2', - ]); - expect(events[0]).toHaveMetadataValue([ - EventMetadataKey.GEMINI_CLI_AI_ADDED_CHARS, - '3', - ]); - expect(events[0]).toHaveMetadataValue([ - EventMetadataKey.GEMINI_CLI_AI_REMOVED_CHARS, - '4', - ]); - expect(events[0]).not.toHaveMetadataKey( - EventMetadataKey.GEMINI_CLI_USER_ADDED_LINES, - ); - expect(events[0]).not.toHaveMetadataKey( - EventMetadataKey.GEMINI_CLI_USER_REMOVED_LINES, - ); - expect(events[0]).not.toHaveMetadataKey( - EventMetadataKey.GEMINI_CLI_USER_ADDED_CHARS, - ); - expect(events[0]).not.toHaveMetadataKey( - EventMetadataKey.GEMINI_CLI_USER_REMOVED_CHARS, - ); - }); - - it('does not log diff metadata if diffStat is not present', () => { - const { logger } = setup(); - const completedToolCall = { - request: { name: 'test', args: {}, prompt_id: 'prompt-123' }, - response: { - resultDisplay: {}, - }, - status: 'success', - } as SuccessfulToolCall; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - logger?.logToolCallEvent(new ToolCallEvent(completedToolCall as any)); - - const events = getEvents(logger!); - expect(events.length).toBe(1); - expect(events[0]).toHaveEventName(EventNames.TOOL_CALL); - expect(events[0]).not.toHaveMetadataKey( - EventMetadataKey.GEMINI_CLI_AI_ADDED_LINES, - ); - }); - }); -}); diff --git a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts deleted file mode 100644 index 5c75d0c2a..000000000 --- a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts +++ /dev/null @@ -1,1130 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { HttpsProxyAgent } from 'https-proxy-agent'; -import type { - StartSessionEvent, - UserPromptEvent, - ToolCallEvent, - ApiRequestEvent, - ApiResponseEvent, - ApiErrorEvent, - LoopDetectedEvent, - NextSpeakerCheckEvent, - SlashCommandEvent, - MalformedJsonResponseEvent, - IdeConnectionEvent, - ConversationFinishedEvent, - KittySequenceOverflowEvent, - ChatCompressionEvent, - FileOperationEvent, - InvalidChunkEvent, - ContentRetryEvent, - ContentRetryFailureEvent, - ExtensionInstallEvent, - ToolOutputTruncatedEvent, - ExtensionUninstallEvent, - ExtensionEnableEvent, - ModelSlashCommandEvent, - ExtensionDisableEvent, -} from '../types.js'; -import { EventMetadataKey } from './event-metadata-key.js'; -import type { Config } from '../../config/config.js'; -import { InstallationManager } from '../../utils/installationManager.js'; -import { safeJsonStringify } from '../../utils/safeJsonStringify.js'; -import { FixedDeque } from 'mnemonist'; -import { GIT_COMMIT_INFO, CLI_VERSION } from '../../generated/git-commit.js'; -import { - IDE_DEFINITIONS, - detectIdeFromEnv, - isCloudShell, -} from '../../ide/detect-ide.js'; - -export enum EventNames { - START_SESSION = 'start_session', - NEW_PROMPT = 'new_prompt', - TOOL_CALL = 'tool_call', - FILE_OPERATION = 'file_operation', - API_REQUEST = 'api_request', - API_RESPONSE = 'api_response', - API_ERROR = 'api_error', - END_SESSION = 'end_session', - FLASH_FALLBACK = 'flash_fallback', - RIPGREP_FALLBACK = 'ripgrep_fallback', - LOOP_DETECTED = 'loop_detected', - LOOP_DETECTION_DISABLED = 'loop_detection_disabled', - NEXT_SPEAKER_CHECK = 'next_speaker_check', - SLASH_COMMAND = 'slash_command', - MALFORMED_JSON_RESPONSE = 'malformed_json_response', - IDE_CONNECTION = 'ide_connection', - KITTY_SEQUENCE_OVERFLOW = 'kitty_sequence_overflow', - CHAT_COMPRESSION = 'chat_compression', - CONVERSATION_FINISHED = 'conversation_finished', - INVALID_CHUNK = 'invalid_chunk', - CONTENT_RETRY = 'content_retry', - CONTENT_RETRY_FAILURE = 'content_retry_failure', - EXTENSION_ENABLE = 'extension_enable', - EXTENSION_DISABLE = 'extension_disable', - EXTENSION_INSTALL = 'extension_install', - EXTENSION_UNINSTALL = 'extension_uninstall', - TOOL_OUTPUT_TRUNCATED = 'tool_output_truncated', - MODEL_SLASH_COMMAND = 'model_slash_command', -} - -export interface LogResponse { - nextRequestWaitMs?: number; -} - -export interface LogEventEntry { - event_time_ms: number; - source_extension_json: string; -} - -export interface EventValue { - gemini_cli_key: EventMetadataKey; - value: string; -} - -export interface LogEvent { - console_type: 'GEMINI_CLI'; - application: number; - event_name: string; - event_metadata: EventValue[][]; - client_email?: string; - client_install_id?: string; -} - -export interface LogRequest { - log_source_name: 'CONCORD'; - request_time_ms: number; - log_event: LogEventEntry[][]; -} - -/** - * Determine the surface that the user is currently using. Surface is effectively the - * distribution channel in which the user is using Gemini CLI. Gemini CLI comes bundled - * w/ Firebase Studio and Cloud Shell. Users that manually download themselves will - * likely be "SURFACE_NOT_SET". - * - * This is computed based upon a series of environment variables these distribution - * methods might have in their runtimes. - */ -function determineSurface(): string { - if (process.env['SURFACE']) { - return process.env['SURFACE']; - } else if (isCloudShell()) { - return IDE_DEFINITIONS.cloudshell.name; - } else if (process.env['GITHUB_SHA']) { - return 'GitHub'; - } else if (process.env['TERM_PROGRAM'] === 'vscode') { - return detectIdeFromEnv().name || IDE_DEFINITIONS.vscode.name; - } else { - return 'SURFACE_NOT_SET'; - } -} - -/** - * Clearcut URL to send logging events to. - */ -const CLEARCUT_URL = 'https://play.googleapis.com/log?format=json&hasfast=true'; - -/** - * Interval in which buffered events are sent to clearcut. - */ -const FLUSH_INTERVAL_MS = 1000 * 60; - -/** - * Maximum amount of events to keep in memory. Events added after this amount - * are dropped until the next flush to clearcut, which happens periodically as - * defined by {@link FLUSH_INTERVAL_MS}. - */ -const MAX_EVENTS = 1000; - -/** - * Maximum events to retry after a failed clearcut flush - */ -const MAX_RETRY_EVENTS = 100; - -// Singleton class for batch posting log events to Clearcut. When a new event comes in, the elapsed time -// is checked and events are flushed to Clearcut if at least a minute has passed since the last flush. -export class ClearcutLogger { - private static instance: ClearcutLogger; - private config?: Config; - private sessionData: EventValue[] = []; - private promptId: string = ''; - private readonly installationManager: InstallationManager; - - /** - * Queue of pending events that need to be flushed to the server. New events - * are added to this queue and then flushed on demand (via `flushToClearcut`) - */ - private readonly events: FixedDeque; - - /** - * The last time that the events were successfully flushed to the server. - */ - private lastFlushTime: number = Date.now(); - - /** - * the value is true when there is a pending flush happening. This prevents - * concurrent flush operations. - */ - private flushing: boolean = false; - - /** - * This value is true when a flush was requested during an ongoing flush. - */ - private pendingFlush: boolean = false; - - private constructor(config: Config) { - this.config = config; - this.events = new FixedDeque(Array, MAX_EVENTS); - this.promptId = config?.getSessionId() ?? ''; - this.installationManager = new InstallationManager(); - } - - static getInstance(config?: Config): ClearcutLogger | undefined { - if (config === undefined || !config?.getUsageStatisticsEnabled()) - return undefined; - if (!ClearcutLogger.instance) { - ClearcutLogger.instance = new ClearcutLogger(config); - } - return ClearcutLogger.instance; - } - - /** For testing purposes only. */ - static clearInstance(): void { - // @ts-expect-error - ClearcutLogger is a singleton, but we need to clear it for tests. - ClearcutLogger.instance = undefined; - } - - enqueueLogEvent(event: LogEvent): void { - try { - // Manually handle overflow for FixedDeque, which throws when full. - const wasAtCapacity = this.events.size >= MAX_EVENTS; - - if (wasAtCapacity) { - this.events.shift(); // Evict oldest element to make space. - } - - this.events.push([ - { - event_time_ms: Date.now(), - source_extension_json: safeJsonStringify(event), - }, - ]); - - if (wasAtCapacity && this.config?.getDebugMode()) { - console.debug( - `ClearcutLogger: Dropped old event to prevent memory leak (queue size: ${this.events.size})`, - ); - } - } catch (error) { - if (this.config?.getDebugMode()) { - console.error('ClearcutLogger: Failed to enqueue log event.', error); - } - } - } - - createLogEvent(eventName: EventNames, data: EventValue[] = []): LogEvent { - if (eventName !== EventNames.START_SESSION) { - data.push(...this.sessionData); - } - - data = this.addDefaultFields(data); - - const logEvent: LogEvent = { - console_type: 'GEMINI_CLI', - application: 102, // GEMINI_CLI - event_name: eventName as string, - event_metadata: [data], - }; - - logEvent.client_install_id = this.installationManager.getInstallationId(); - - return logEvent; - } - - flushIfNeeded(): void { - if (Date.now() - this.lastFlushTime < FLUSH_INTERVAL_MS) { - return; - } - - this.flushToClearcut().catch((error) => { - console.debug('Error flushing to Clearcut:', error); - }); - } - - async flushToClearcut(): Promise { - if (this.flushing) { - if (this.config?.getDebugMode()) { - console.debug( - 'ClearcutLogger: Flush already in progress, marking pending flush.', - ); - } - this.pendingFlush = true; - return Promise.resolve({}); - } - this.flushing = true; - - if (this.config?.getDebugMode()) { - console.log('Flushing log events to Clearcut.'); - } - const eventsToSend = this.events.toArray() as LogEventEntry[][]; - this.events.clear(); - - const request: LogRequest[] = [ - { - log_source_name: 'CONCORD', - request_time_ms: Date.now(), - log_event: eventsToSend, - }, - ]; - - let result: LogResponse = {}; - - try { - const response = await fetch(CLEARCUT_URL, { - method: 'POST', - body: safeJsonStringify(request), - headers: { - 'Content-Type': 'application/json', - }, - }); - - const responseBody = await response.text(); - - if (response.status >= 200 && response.status < 300) { - this.lastFlushTime = Date.now(); - const nextRequestWaitMs = Number(JSON.parse(responseBody)[0]); - result = { - ...result, - nextRequestWaitMs, - }; - } else { - if (this.config?.getDebugMode()) { - console.error( - `Error flushing log events: HTTP ${response.status}: ${response.statusText}`, - ); - } - - // Re-queue failed events for retry - this.requeueFailedEvents(eventsToSend); - } - } catch (e: unknown) { - if (this.config?.getDebugMode()) { - console.error('Error flushing log events:', e as Error); - } - - // Re-queue failed events for retry - this.requeueFailedEvents(eventsToSend); - } - - this.flushing = false; - - // If a flush was requested while we were flushing, flush again - if (this.pendingFlush) { - this.pendingFlush = false; - // Fire and forget the pending flush - this.flushToClearcut().catch((error) => { - if (this.config?.getDebugMode()) { - console.debug('Error in pending flush to Clearcut:', error); - } - }); - } - - return result; - } - - logStartSessionEvent(event: StartSessionEvent): void { - const data: EventValue[] = [ - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_START_SESSION_MODEL, - value: event.model, - }, - { - gemini_cli_key: - EventMetadataKey.GEMINI_CLI_START_SESSION_EMBEDDING_MODEL, - value: event.embedding_model, - }, - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_START_SESSION_SANDBOX, - value: event.sandbox_enabled.toString(), - }, - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_START_SESSION_CORE_TOOLS, - value: event.core_tools_enabled, - }, - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_START_SESSION_APPROVAL_MODE, - value: event.approval_mode, - }, - { - gemini_cli_key: - EventMetadataKey.GEMINI_CLI_START_SESSION_API_KEY_ENABLED, - value: event.api_key_enabled.toString(), - }, - { - gemini_cli_key: - EventMetadataKey.GEMINI_CLI_START_SESSION_VERTEX_API_ENABLED, - value: event.vertex_ai_enabled.toString(), - }, - { - gemini_cli_key: - EventMetadataKey.GEMINI_CLI_START_SESSION_DEBUG_MODE_ENABLED, - value: event.debug_enabled.toString(), - }, - { - gemini_cli_key: - EventMetadataKey.GEMINI_CLI_START_SESSION_VERTEX_API_ENABLED, - value: event.vertex_ai_enabled.toString(), - }, - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_START_SESSION_MCP_SERVERS, - value: event.mcp_servers, - }, - { - gemini_cli_key: - EventMetadataKey.GEMINI_CLI_START_SESSION_VERTEX_API_ENABLED, - value: event.vertex_ai_enabled.toString(), - }, - { - gemini_cli_key: - EventMetadataKey.GEMINI_CLI_START_SESSION_TELEMETRY_ENABLED, - value: event.telemetry_enabled.toString(), - }, - { - gemini_cli_key: - EventMetadataKey.GEMINI_CLI_START_SESSION_TELEMETRY_LOG_USER_PROMPTS_ENABLED, - value: event.telemetry_log_user_prompts_enabled.toString(), - }, - { - gemini_cli_key: - EventMetadataKey.GEMINI_CLI_START_SESSION_MCP_SERVERS_COUNT, - value: event.mcp_servers_count - ? event.mcp_servers_count.toString() - : '', - }, - { - gemini_cli_key: - EventMetadataKey.GEMINI_CLI_START_SESSION_MCP_TOOLS_COUNT, - value: event.mcp_tools_count?.toString() ?? '', - }, - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_START_SESSION_MCP_TOOLS, - value: event.mcp_tools ? event.mcp_tools : '', - }, - ]; - this.sessionData = data; - - // Flush start event immediately - this.enqueueLogEvent(this.createLogEvent(EventNames.START_SESSION, data)); - this.flushToClearcut().catch((error) => { - console.debug('Error flushing to Clearcut:', error); - }); - } - - logNewPromptEvent(event: UserPromptEvent): void { - this.promptId = event.prompt_id; - const data: EventValue[] = [ - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_USER_PROMPT_LENGTH, - value: JSON.stringify(event.prompt_length), - }, - ]; - - this.enqueueLogEvent(this.createLogEvent(EventNames.NEW_PROMPT, data)); - this.flushIfNeeded(); - } - - logToolCallEvent(event: ToolCallEvent): void { - const data: EventValue[] = [ - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_TOOL_CALL_NAME, - value: JSON.stringify(event.function_name), - }, - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_TOOL_CALL_DECISION, - value: JSON.stringify(event.decision), - }, - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_TOOL_CALL_SUCCESS, - value: JSON.stringify(event.success), - }, - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_TOOL_CALL_DURATION_MS, - value: JSON.stringify(event.duration_ms), - }, - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_TOOL_ERROR_MESSAGE, - value: JSON.stringify(event.error), - }, - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_TOOL_CALL_ERROR_TYPE, - value: JSON.stringify(event.error_type), - }, - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_TOOL_TYPE, - value: JSON.stringify(event.tool_type), - }, - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_TOOL_CALL_CONTENT_LENGTH, - value: JSON.stringify(event.content_length), - }, - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_TOOL_CALL_MCP_SERVER_NAME, - value: JSON.stringify(event.mcp_server_name), - }, - ]; - - if (event.metadata) { - const metadataMapping: { [key: string]: EventMetadataKey } = { - model_added_lines: EventMetadataKey.GEMINI_CLI_AI_ADDED_LINES, - model_removed_lines: EventMetadataKey.GEMINI_CLI_AI_REMOVED_LINES, - model_added_chars: EventMetadataKey.GEMINI_CLI_AI_ADDED_CHARS, - model_removed_chars: EventMetadataKey.GEMINI_CLI_AI_REMOVED_CHARS, - user_added_lines: EventMetadataKey.GEMINI_CLI_USER_ADDED_LINES, - user_removed_lines: EventMetadataKey.GEMINI_CLI_USER_REMOVED_LINES, - user_added_chars: EventMetadataKey.GEMINI_CLI_USER_ADDED_CHARS, - user_removed_chars: EventMetadataKey.GEMINI_CLI_USER_REMOVED_CHARS, - }; - - for (const [key, gemini_cli_key] of Object.entries(metadataMapping)) { - if (event.metadata[key] !== undefined) { - data.push({ - gemini_cli_key, - value: JSON.stringify(event.metadata[key]), - }); - } - } - } - - const logEvent = this.createLogEvent(EventNames.TOOL_CALL, data); - this.enqueueLogEvent(logEvent); - this.flushIfNeeded(); - } - - logFileOperationEvent(event: FileOperationEvent): void { - const data: EventValue[] = [ - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_TOOL_CALL_NAME, - value: JSON.stringify(event.tool_name), - }, - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_FILE_OPERATION_TYPE, - value: JSON.stringify(event.operation), - }, - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_FILE_OPERATION_LINES, - value: JSON.stringify(event.lines), - }, - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_FILE_OPERATION_MIMETYPE, - value: JSON.stringify(event.mimetype), - }, - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_FILE_OPERATION_EXTENSION, - value: JSON.stringify(event.extension), - }, - ]; - - if (event.programming_language) { - data.push({ - gemini_cli_key: EventMetadataKey.GEMINI_CLI_PROGRAMMING_LANGUAGE, - value: event.programming_language, - }); - } - - const logEvent = this.createLogEvent(EventNames.FILE_OPERATION, data); - this.enqueueLogEvent(logEvent); - this.flushIfNeeded(); - } - - logApiRequestEvent(event: ApiRequestEvent): void { - const data: EventValue[] = [ - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_API_REQUEST_MODEL, - value: JSON.stringify(event.model), - }, - ]; - - this.enqueueLogEvent(this.createLogEvent(EventNames.API_REQUEST, data)); - this.flushIfNeeded(); - } - - logApiResponseEvent(event: ApiResponseEvent): void { - const data: EventValue[] = [ - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_API_RESPONSE_MODEL, - value: JSON.stringify(event.model), - }, - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_API_RESPONSE_STATUS_CODE, - value: JSON.stringify(event.status_code), - }, - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_API_RESPONSE_DURATION_MS, - value: JSON.stringify(event.duration_ms), - }, - { - gemini_cli_key: - EventMetadataKey.GEMINI_CLI_API_RESPONSE_INPUT_TOKEN_COUNT, - value: JSON.stringify(event.input_token_count), - }, - { - gemini_cli_key: - EventMetadataKey.GEMINI_CLI_API_RESPONSE_OUTPUT_TOKEN_COUNT, - value: JSON.stringify(event.output_token_count), - }, - { - gemini_cli_key: - EventMetadataKey.GEMINI_CLI_API_RESPONSE_CACHED_TOKEN_COUNT, - value: JSON.stringify(event.cached_content_token_count), - }, - { - gemini_cli_key: - EventMetadataKey.GEMINI_CLI_API_RESPONSE_THINKING_TOKEN_COUNT, - value: JSON.stringify(event.thoughts_token_count), - }, - { - gemini_cli_key: - EventMetadataKey.GEMINI_CLI_API_RESPONSE_TOOL_TOKEN_COUNT, - value: JSON.stringify(event.tool_token_count), - }, - ]; - - this.enqueueLogEvent(this.createLogEvent(EventNames.API_RESPONSE, data)); - this.flushIfNeeded(); - } - - logApiErrorEvent(event: ApiErrorEvent): void { - const data: EventValue[] = [ - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_API_ERROR_MODEL, - value: JSON.stringify(event.model), - }, - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_API_ERROR_TYPE, - value: JSON.stringify(event.error_type), - }, - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_API_ERROR_STATUS_CODE, - value: JSON.stringify(event.status_code), - }, - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_API_ERROR_DURATION_MS, - value: JSON.stringify(event.duration_ms), - }, - ]; - - this.enqueueLogEvent(this.createLogEvent(EventNames.API_ERROR, data)); - this.flushIfNeeded(); - } - - logChatCompressionEvent(event: ChatCompressionEvent): void { - const data: EventValue[] = [ - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_COMPRESSION_TOKENS_BEFORE, - value: `${event.tokens_before}`, - }, - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_COMPRESSION_TOKENS_AFTER, - value: `${event.tokens_after}`, - }, - ]; - - this.enqueueLogEvent( - this.createLogEvent(EventNames.CHAT_COMPRESSION, data), - ); - } - - logFlashFallbackEvent(): void { - this.enqueueLogEvent(this.createLogEvent(EventNames.FLASH_FALLBACK, [])); - this.flushToClearcut().catch((error) => { - console.debug('Error flushing to Clearcut:', error); - }); - } - - logRipgrepFallbackEvent(): void { - this.enqueueLogEvent(this.createLogEvent(EventNames.RIPGREP_FALLBACK, [])); - this.flushToClearcut().catch((error) => { - console.debug('Error flushing to Clearcut:', error); - }); - } - - logLoopDetectedEvent(event: LoopDetectedEvent): void { - const data: EventValue[] = [ - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_LOOP_DETECTED_TYPE, - value: JSON.stringify(event.loop_type), - }, - ]; - - this.enqueueLogEvent(this.createLogEvent(EventNames.LOOP_DETECTED, data)); - this.flushIfNeeded(); - } - - logLoopDetectionDisabledEvent(): void { - const data: EventValue[] = []; - - this.enqueueLogEvent( - this.createLogEvent(EventNames.LOOP_DETECTION_DISABLED, data), - ); - this.flushIfNeeded(); - } - - logNextSpeakerCheck(event: NextSpeakerCheckEvent): void { - const data: EventValue[] = [ - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_RESPONSE_FINISH_REASON, - value: JSON.stringify(event.finish_reason), - }, - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_NEXT_SPEAKER_CHECK_RESULT, - value: JSON.stringify(event.result), - }, - ]; - - this.enqueueLogEvent( - this.createLogEvent(EventNames.NEXT_SPEAKER_CHECK, data), - ); - this.flushIfNeeded(); - } - - logSlashCommandEvent(event: SlashCommandEvent): void { - const data: EventValue[] = [ - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_SLASH_COMMAND_NAME, - value: JSON.stringify(event.command), - }, - ]; - - if (event.subcommand) { - data.push({ - gemini_cli_key: EventMetadataKey.GEMINI_CLI_SLASH_COMMAND_SUBCOMMAND, - value: JSON.stringify(event.subcommand), - }); - } - - if (event.status) { - data.push({ - gemini_cli_key: EventMetadataKey.GEMINI_CLI_SLASH_COMMAND_STATUS, - value: JSON.stringify(event.status), - }); - } - - this.enqueueLogEvent(this.createLogEvent(EventNames.SLASH_COMMAND, data)); - this.flushIfNeeded(); - } - - logMalformedJsonResponseEvent(event: MalformedJsonResponseEvent): void { - const data: EventValue[] = [ - { - gemini_cli_key: - EventMetadataKey.GEMINI_CLI_MALFORMED_JSON_RESPONSE_MODEL, - value: JSON.stringify(event.model), - }, - ]; - - this.enqueueLogEvent( - this.createLogEvent(EventNames.MALFORMED_JSON_RESPONSE, data), - ); - this.flushIfNeeded(); - } - - logIdeConnectionEvent(event: IdeConnectionEvent): void { - const data: EventValue[] = [ - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_IDE_CONNECTION_TYPE, - value: JSON.stringify(event.connection_type), - }, - ]; - - this.enqueueLogEvent(this.createLogEvent(EventNames.IDE_CONNECTION, data)); - this.flushIfNeeded(); - } - - logConversationFinishedEvent(event: ConversationFinishedEvent): void { - const data: EventValue[] = [ - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_SESSION_ID, - value: this.config?.getSessionId() ?? '', - }, - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_CONVERSATION_TURN_COUNT, - value: JSON.stringify(event.turnCount), - }, - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_APPROVAL_MODE, - value: event.approvalMode, - }, - ]; - - this.enqueueLogEvent( - this.createLogEvent(EventNames.CONVERSATION_FINISHED, data), - ); - this.flushIfNeeded(); - } - - logKittySequenceOverflowEvent(event: KittySequenceOverflowEvent): void { - const data: EventValue[] = [ - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_KITTY_SEQUENCE_LENGTH, - value: event.sequence_length.toString(), - }, - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_KITTY_TRUNCATED_SEQUENCE, - value: event.truncated_sequence, - }, - ]; - - this.enqueueLogEvent( - this.createLogEvent(EventNames.KITTY_SEQUENCE_OVERFLOW, data), - ); - this.flushIfNeeded(); - } - - logEndSessionEvent(): void { - // Flush immediately on session end. - this.enqueueLogEvent(this.createLogEvent(EventNames.END_SESSION, [])); - this.flushToClearcut().catch((error) => { - console.debug('Error flushing to Clearcut:', error); - }); - } - - logInvalidChunkEvent(event: InvalidChunkEvent): void { - const data: EventValue[] = []; - - if (event.error_message) { - data.push({ - gemini_cli_key: EventMetadataKey.GEMINI_CLI_INVALID_CHUNK_ERROR_MESSAGE, - value: event.error_message, - }); - } - - this.enqueueLogEvent(this.createLogEvent(EventNames.INVALID_CHUNK, data)); - this.flushIfNeeded(); - } - - logContentRetryEvent(event: ContentRetryEvent): void { - const data: EventValue[] = [ - { - gemini_cli_key: - EventMetadataKey.GEMINI_CLI_CONTENT_RETRY_ATTEMPT_NUMBER, - value: String(event.attempt_number), - }, - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_CONTENT_RETRY_ERROR_TYPE, - value: event.error_type, - }, - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_CONTENT_RETRY_DELAY_MS, - value: String(event.retry_delay_ms), - }, - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_API_REQUEST_MODEL, - value: event.model, - }, - ]; - - this.enqueueLogEvent(this.createLogEvent(EventNames.CONTENT_RETRY, data)); - this.flushIfNeeded(); - } - - logContentRetryFailureEvent(event: ContentRetryFailureEvent): void { - const data: EventValue[] = [ - { - gemini_cli_key: - EventMetadataKey.GEMINI_CLI_CONTENT_RETRY_FAILURE_TOTAL_ATTEMPTS, - value: String(event.total_attempts), - }, - { - gemini_cli_key: - EventMetadataKey.GEMINI_CLI_CONTENT_RETRY_FAILURE_FINAL_ERROR_TYPE, - value: event.final_error_type, - }, - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_API_REQUEST_MODEL, - value: event.model, - }, - ]; - - if (event.total_duration_ms) { - data.push({ - gemini_cli_key: - EventMetadataKey.GEMINI_CLI_CONTENT_RETRY_FAILURE_TOTAL_DURATION_MS, - value: String(event.total_duration_ms), - }); - } - - this.enqueueLogEvent( - this.createLogEvent(EventNames.CONTENT_RETRY_FAILURE, data), - ); - this.flushIfNeeded(); - } - - logExtensionInstallEvent(event: ExtensionInstallEvent): void { - const data: EventValue[] = [ - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_EXTENSION_NAME, - value: event.extension_name, - }, - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_EXTENSION_VERSION, - value: event.extension_version, - }, - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_EXTENSION_SOURCE, - value: event.extension_source, - }, - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_EXTENSION_INSTALL_STATUS, - value: event.status, - }, - ]; - - this.enqueueLogEvent( - this.createLogEvent(EventNames.EXTENSION_INSTALL, data), - ); - this.flushToClearcut().catch((error) => { - console.debug('Error flushing to Clearcut:', error); - }); - } - - logExtensionUninstallEvent(event: ExtensionUninstallEvent): void { - const data: EventValue[] = [ - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_EXTENSION_NAME, - value: event.extension_name, - }, - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_EXTENSION_UNINSTALL_STATUS, - value: event.status, - }, - ]; - - this.enqueueLogEvent( - this.createLogEvent(EventNames.EXTENSION_UNINSTALL, data), - ); - this.flushToClearcut().catch((error) => { - console.debug('Error flushing to Clearcut:', error); - }); - } - - logToolOutputTruncatedEvent(event: ToolOutputTruncatedEvent): void { - const data: EventValue[] = [ - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_TOOL_CALL_NAME, - value: JSON.stringify(event.tool_name), - }, - { - gemini_cli_key: - EventMetadataKey.GEMINI_CLI_TOOL_OUTPUT_TRUNCATED_ORIGINAL_LENGTH, - value: JSON.stringify(event.original_content_length), - }, - { - gemini_cli_key: - EventMetadataKey.GEMINI_CLI_TOOL_OUTPUT_TRUNCATED_TRUNCATED_LENGTH, - value: JSON.stringify(event.truncated_content_length), - }, - { - gemini_cli_key: - EventMetadataKey.GEMINI_CLI_TOOL_OUTPUT_TRUNCATED_THRESHOLD, - value: JSON.stringify(event.threshold), - }, - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_TOOL_OUTPUT_TRUNCATED_LINES, - value: JSON.stringify(event.lines), - }, - ]; - - this.enqueueLogEvent( - this.createLogEvent(EventNames.TOOL_OUTPUT_TRUNCATED, data), - ); - this.flushIfNeeded(); - } - - logExtensionEnableEvent(event: ExtensionEnableEvent): void { - const data: EventValue[] = [ - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_EXTENSION_NAME, - value: event.extension_name, - }, - { - gemini_cli_key: - EventMetadataKey.GEMINI_CLI_EXTENSION_ENABLE_SETTING_SCOPE, - value: event.setting_scope, - }, - ]; - - this.enqueueLogEvent( - this.createLogEvent(EventNames.EXTENSION_ENABLE, data), - ); - this.flushToClearcut().catch((error) => { - console.debug('Error flushing to Clearcut:', error); - }); - } - - logModelSlashCommandEvent(event: ModelSlashCommandEvent): void { - const data: EventValue[] = [ - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_MODEL_SLASH_COMMAND, - value: event.model_name, - }, - ]; - - this.enqueueLogEvent( - this.createLogEvent(EventNames.MODEL_SLASH_COMMAND, data), - ); - this.flushIfNeeded(); - } - - logExtensionDisableEvent(event: ExtensionDisableEvent): void { - const data: EventValue[] = [ - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_EXTENSION_NAME, - value: event.extension_name, - }, - { - gemini_cli_key: - EventMetadataKey.GEMINI_CLI_EXTENSION_DISABLE_SETTING_SCOPE, - value: event.setting_scope, - }, - ]; - - this.enqueueLogEvent( - this.createLogEvent(EventNames.EXTENSION_DISABLE, data), - ); - this.flushToClearcut().catch((error) => { - console.debug('Error flushing to Clearcut:', error); - }); - } - - /** - * Adds default fields to data, and returns a new data array. This fields - * should exist on all log events. - */ - addDefaultFields(data: EventValue[]): EventValue[] { - const surface = determineSurface(); - - const defaultLogMetadata: EventValue[] = [ - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_SESSION_ID, - value: this.config?.getSessionId() ?? '', - }, - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_AUTH_TYPE, - value: JSON.stringify( - this.config?.getContentGeneratorConfig()?.authType, - ), - }, - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_SURFACE, - value: surface, - }, - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_VERSION, - value: CLI_VERSION, - }, - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_GIT_COMMIT_HASH, - value: GIT_COMMIT_INFO, - }, - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_PROMPT_ID, - value: this.promptId, - }, - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_OS, - value: process.platform, - }, - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_NODE_VERSION, - value: process.versions.node, - }, - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_USER_SETTINGS, - value: safeJsonStringify([ - { - smart_edit_enabled: this.config?.getUseSmartEdit() ?? false, - }, - ]), - }, - ]; - return [...data, ...defaultLogMetadata]; - } - - getProxyAgent() { - const proxyUrl = this.config?.getProxy(); - if (!proxyUrl) return undefined; - // undici which is widely used in the repo can only support http & https proxy protocol, - // https://github.com/nodejs/undici/issues/2224 - if (proxyUrl.startsWith('http')) { - return new HttpsProxyAgent(proxyUrl); - } else { - throw new Error('Unsupported proxy type'); - } - } - - shutdown() { - this.logEndSessionEvent(); - } - - private requeueFailedEvents(eventsToSend: LogEventEntry[][]): void { - // Add the events back to the front of the queue to be retried, but limit retry queue size - const eventsToRetry = eventsToSend.slice(-MAX_RETRY_EVENTS); // Keep only the most recent events - - // Log a warning if we're dropping events - if (eventsToSend.length > MAX_RETRY_EVENTS && this.config?.getDebugMode()) { - console.warn( - `ClearcutLogger: Dropping ${ - eventsToSend.length - MAX_RETRY_EVENTS - } events due to retry queue limit. Total events: ${ - eventsToSend.length - }, keeping: ${MAX_RETRY_EVENTS}`, - ); - } - - // Determine how many events can be re-queued - const availableSpace = MAX_EVENTS - this.events.size; - const numEventsToRequeue = Math.min(eventsToRetry.length, availableSpace); - - if (numEventsToRequeue === 0) { - if (this.config?.getDebugMode()) { - console.debug( - `ClearcutLogger: No events re-queued (queue size: ${this.events.size})`, - ); - } - return; - } - - // Get the most recent events to re-queue - const eventsToRequeue = eventsToRetry.slice( - eventsToRetry.length - numEventsToRequeue, - ); - - // Prepend events to the front of the deque to be retried first. - // We iterate backwards to maintain the original order of the failed events. - for (let i = eventsToRequeue.length - 1; i >= 0; i--) { - this.events.unshift(eventsToRequeue[i]); - } - // Clear any potential overflow - while (this.events.size > MAX_EVENTS) { - this.events.pop(); - } - - if (this.config?.getDebugMode()) { - console.debug( - `ClearcutLogger: Re-queued ${numEventsToRequeue} events for retry (queue size: ${this.events.size})`, - ); - } - } -} - -export const TEST_ONLY = { - MAX_RETRY_EVENTS, - MAX_EVENTS, -}; diff --git a/packages/core/src/telemetry/clearcut-logger/event-metadata-key.ts b/packages/core/src/telemetry/clearcut-logger/event-metadata-key.ts deleted file mode 100644 index a7504c0da..000000000 --- a/packages/core/src/telemetry/clearcut-logger/event-metadata-key.ts +++ /dev/null @@ -1,419 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -// Defines valid event metadata keys for Qwen logging. -export enum EventMetadataKey { - // Deleted enums: 24 - - GEMINI_CLI_KEY_UNKNOWN = 0, - - // ========================================================================== - // Start Session Event Keys - // =========================================================================== - - // Logs the model id used in the session. - GEMINI_CLI_START_SESSION_MODEL = 1, - - // Logs the embedding model id used in the session. - GEMINI_CLI_START_SESSION_EMBEDDING_MODEL = 2, - - // Logs the sandbox that was used in the session. - GEMINI_CLI_START_SESSION_SANDBOX = 3, - - // Logs the core tools that were enabled in the session. - GEMINI_CLI_START_SESSION_CORE_TOOLS = 4, - - // Logs the approval mode that was used in the session. - GEMINI_CLI_START_SESSION_APPROVAL_MODE = 5, - - // Logs whether an API key was used in the session. - GEMINI_CLI_START_SESSION_API_KEY_ENABLED = 6, - - // Logs whether the Vertex API was used in the session. - GEMINI_CLI_START_SESSION_VERTEX_API_ENABLED = 7, - - // Logs whether debug mode was enabled in the session. - GEMINI_CLI_START_SESSION_DEBUG_MODE_ENABLED = 8, - - // Logs the MCP servers that were enabled in the session. - GEMINI_CLI_START_SESSION_MCP_SERVERS = 9, - - // Logs whether user-collected telemetry was enabled in the session. - GEMINI_CLI_START_SESSION_TELEMETRY_ENABLED = 10, - - // Logs whether prompt collection was enabled for user-collected telemetry. - GEMINI_CLI_START_SESSION_TELEMETRY_LOG_USER_PROMPTS_ENABLED = 11, - - // Logs whether the session was configured to respect gitignore files. - GEMINI_CLI_START_SESSION_RESPECT_GITIGNORE = 12, - - // Logs the output format of the session. - GEMINI_CLI_START_SESSION_OUTPUT_FORMAT = 94, - - // ========================================================================== - // User Prompt Event Keys - // =========================================================================== - - // Logs the length of the prompt. - GEMINI_CLI_USER_PROMPT_LENGTH = 13, - - // ========================================================================== - // Tool Call Event Keys - // =========================================================================== - - // Logs the function name. - GEMINI_CLI_TOOL_CALL_NAME = 14, - - // Logs the MCP server name. - GEMINI_CLI_TOOL_CALL_MCP_SERVER_NAME = 95, - - // Logs the user's decision about how to handle the tool call. - GEMINI_CLI_TOOL_CALL_DECISION = 15, - - // Logs whether the tool call succeeded. - GEMINI_CLI_TOOL_CALL_SUCCESS = 16, - - // Logs the tool call duration in milliseconds. - GEMINI_CLI_TOOL_CALL_DURATION_MS = 17, - - // Logs the tool call error message, if any. - GEMINI_CLI_TOOL_ERROR_MESSAGE = 18, - - // Logs the tool call error type, if any. - GEMINI_CLI_TOOL_CALL_ERROR_TYPE = 19, - - // Logs the length of tool output - GEMINI_CLI_TOOL_CALL_CONTENT_LENGTH = 93, - - // ========================================================================== - // GenAI API Request Event Keys - // =========================================================================== - - // Logs the model id of the request. - GEMINI_CLI_API_REQUEST_MODEL = 20, - - // ========================================================================== - // GenAI API Response Event Keys - // =========================================================================== - - // Logs the model id of the API call. - GEMINI_CLI_API_RESPONSE_MODEL = 21, - - // Logs the status code of the response. - GEMINI_CLI_API_RESPONSE_STATUS_CODE = 22, - - // Logs the duration of the API call in milliseconds. - GEMINI_CLI_API_RESPONSE_DURATION_MS = 23, - - // Logs the input token count of the API call. - GEMINI_CLI_API_RESPONSE_INPUT_TOKEN_COUNT = 25, - - // Logs the output token count of the API call. - GEMINI_CLI_API_RESPONSE_OUTPUT_TOKEN_COUNT = 26, - - // Logs the cached token count of the API call. - GEMINI_CLI_API_RESPONSE_CACHED_TOKEN_COUNT = 27, - - // Logs the thinking token count of the API call. - GEMINI_CLI_API_RESPONSE_THINKING_TOKEN_COUNT = 28, - - // Logs the tool use token count of the API call. - GEMINI_CLI_API_RESPONSE_TOOL_TOKEN_COUNT = 29, - - // ========================================================================== - // GenAI API Error Event Keys - // =========================================================================== - - // Logs the model id of the API call. - GEMINI_CLI_API_ERROR_MODEL = 30, - - // Logs the error type. - GEMINI_CLI_API_ERROR_TYPE = 31, - - // Logs the status code of the error response. - GEMINI_CLI_API_ERROR_STATUS_CODE = 32, - - // Logs the duration of the API call in milliseconds. - GEMINI_CLI_API_ERROR_DURATION_MS = 33, - - // ========================================================================== - // End Session Event Keys - // =========================================================================== - - // Logs the end of a session. - GEMINI_CLI_END_SESSION_ID = 34, - - // ========================================================================== - // Shared Keys - // =========================================================================== - - // Logs the Prompt Id - GEMINI_CLI_PROMPT_ID = 35, - - // Logs the Auth type for the prompt, api responses and errors. - GEMINI_CLI_AUTH_TYPE = 36, - - // Logs the total number of Google accounts ever used. - GEMINI_CLI_GOOGLE_ACCOUNTS_COUNT = 37, - - // Logs the Surface from where the Gemini CLI was invoked, eg: VSCode. - GEMINI_CLI_SURFACE = 39, - - // Logs the session id - GEMINI_CLI_SESSION_ID = 40, - - // Logs the Gemini CLI version - GEMINI_CLI_VERSION = 54, - - // Logs the Gemini CLI Git commit hash - GEMINI_CLI_GIT_COMMIT_HASH = 55, - - // Logs the Gemini CLI OS - GEMINI_CLI_OS = 82, - - // Logs active user settings - GEMINI_CLI_USER_SETTINGS = 84, - - // ========================================================================== - // Loop Detected Event Keys - // =========================================================================== - - // Logs the type of loop detected. - GEMINI_CLI_LOOP_DETECTED_TYPE = 38, - - // ========================================================================== - // Slash Command Event Keys - // =========================================================================== - - // Logs the name of the slash command. - GEMINI_CLI_SLASH_COMMAND_NAME = 41, - - // Logs the subcommand of the slash command. - GEMINI_CLI_SLASH_COMMAND_SUBCOMMAND = 42, - - // Logs the status of the slash command (e.g. 'success', 'error') - GEMINI_CLI_SLASH_COMMAND_STATUS = 51, - - // ========================================================================== - // Next Speaker Check Event Keys - // =========================================================================== - - // Logs the finish reason of the previous streamGenerateContent response - GEMINI_CLI_RESPONSE_FINISH_REASON = 43, - - // Logs the result of the next speaker check - GEMINI_CLI_NEXT_SPEAKER_CHECK_RESULT = 44, - - // ========================================================================== - // Malformed JSON Response Event Keys - // ========================================================================== - - // Logs the model that produced the malformed JSON response. - GEMINI_CLI_MALFORMED_JSON_RESPONSE_MODEL = 45, - - // ========================================================================== - // IDE Connection Event Keys - // =========================================================================== - - // Logs the type of the IDE connection. - GEMINI_CLI_IDE_CONNECTION_TYPE = 46, - - // Logs AI added lines in edit/write tool response. - GEMINI_CLI_AI_ADDED_LINES = 47, - - // Logs AI removed lines in edit/write tool response. - GEMINI_CLI_AI_REMOVED_LINES = 48, - - // Logs user added lines in edit/write tool response. - GEMINI_CLI_USER_ADDED_LINES = 49, - - // Logs user removed lines in edit/write tool response. - GEMINI_CLI_USER_REMOVED_LINES = 50, - - // Logs AI added characters in edit/write tool response. - GEMINI_CLI_AI_ADDED_CHARS = 103, - - // Logs AI removed characters in edit/write tool response. - GEMINI_CLI_AI_REMOVED_CHARS = 104, - - // Logs user added characters in edit/write tool response. - GEMINI_CLI_USER_ADDED_CHARS = 105, - - // Logs user removed characters in edit/write tool response. - GEMINI_CLI_USER_REMOVED_CHARS = 106, - - // ========================================================================== - // Kitty Sequence Overflow Event Keys - // =========================================================================== - - // Logs the truncated kitty sequence. - GEMINI_CLI_KITTY_TRUNCATED_SEQUENCE = 52, - - // Logs the length of the kitty sequence that overflowed. - GEMINI_CLI_KITTY_SEQUENCE_LENGTH = 53, - - // ========================================================================== - // Conversation Finished Event Keys - // =========================================================================== - - // Logs the approval mode of the session. - GEMINI_CLI_APPROVAL_MODE = 58, - - // Logs the number of turns - GEMINI_CLI_CONVERSATION_TURN_COUNT = 59, - - // Logs the number of tokens before context window compression. - GEMINI_CLI_COMPRESSION_TOKENS_BEFORE = 60, - - // Logs the number of tokens after context window compression. - GEMINI_CLI_COMPRESSION_TOKENS_AFTER = 61, - - // Logs tool type whether it is mcp or native. - GEMINI_CLI_TOOL_TYPE = 62, - - // Logs count of MCP servers in Start Session Event - GEMINI_CLI_START_SESSION_MCP_SERVERS_COUNT = 63, - - // Logs count of MCP tools in Start Session Event - GEMINI_CLI_START_SESSION_MCP_TOOLS_COUNT = 64, - - // Logs name of MCP tools as comma separated string - GEMINI_CLI_START_SESSION_MCP_TOOLS = 65, - - // ========================================================================== - // Research Event Keys - // =========================================================================== - - // Logs the research opt-in status (true/false) - GEMINI_CLI_RESEARCH_OPT_IN_STATUS = 66, - - // Logs the contact email for research participation - GEMINI_CLI_RESEARCH_CONTACT_EMAIL = 67, - - // Logs the user ID for research events - GEMINI_CLI_RESEARCH_USER_ID = 68, - - // Logs the type of research feedback - GEMINI_CLI_RESEARCH_FEEDBACK_TYPE = 69, - - // Logs the content of research feedback - GEMINI_CLI_RESEARCH_FEEDBACK_CONTENT = 70, - - // Logs survey responses for research feedback (JSON stringified) - GEMINI_CLI_RESEARCH_SURVEY_RESPONSES = 71, - - // ========================================================================== - // File Operation Event Keys - // =========================================================================== - - // Logs the programming language of the project. - GEMINI_CLI_PROGRAMMING_LANGUAGE = 56, - - // Logs the operation type of the file operation. - GEMINI_CLI_FILE_OPERATION_TYPE = 57, - - // Logs the number of lines in the file operation. - GEMINI_CLI_FILE_OPERATION_LINES = 72, - - // Logs the mimetype of the file in the file operation. - GEMINI_CLI_FILE_OPERATION_MIMETYPE = 73, - - // Logs the extension of the file in the file operation. - GEMINI_CLI_FILE_OPERATION_EXTENSION = 74, - - // ========================================================================== - // Content Streaming Event Keys - // =========================================================================== - - // Logs the error message for an invalid chunk. - GEMINI_CLI_INVALID_CHUNK_ERROR_MESSAGE = 75, - - // Logs the attempt number for a content retry. - GEMINI_CLI_CONTENT_RETRY_ATTEMPT_NUMBER = 76, - - // Logs the error type for a content retry. - GEMINI_CLI_CONTENT_RETRY_ERROR_TYPE = 77, - - // Logs the delay in milliseconds for a content retry. - GEMINI_CLI_CONTENT_RETRY_DELAY_MS = 78, - - // Logs the total number of attempts for a content retry failure. - GEMINI_CLI_CONTENT_RETRY_FAILURE_TOTAL_ATTEMPTS = 79, - - // Logs the final error type for a content retry failure. - GEMINI_CLI_CONTENT_RETRY_FAILURE_FINAL_ERROR_TYPE = 80, - - // Logs the total duration in milliseconds for a content retry failure. - GEMINI_CLI_CONTENT_RETRY_FAILURE_TOTAL_DURATION_MS = 81, - - // Logs the current nodejs version - GEMINI_CLI_NODE_VERSION = 83, - - // ========================================================================== - // Extension Install Event Keys - // =========================================================================== - - // Logs the name of the extension. - GEMINI_CLI_EXTENSION_NAME = 85, - - // Logs the version of the extension. - GEMINI_CLI_EXTENSION_VERSION = 86, - - // Logs the source of the extension. - GEMINI_CLI_EXTENSION_SOURCE = 87, - - // Logs the status of the extension install. - GEMINI_CLI_EXTENSION_INSTALL_STATUS = 88, - - // Logs the status of the extension uninstall - GEMINI_CLI_EXTENSION_UNINSTALL_STATUS = 96, - - // Logs the setting scope for an extension enablement. - GEMINI_CLI_EXTENSION_ENABLE_SETTING_SCOPE = 102, - - // Logs the setting scope for an extension disablement. - GEMINI_CLI_EXTENSION_DISABLE_SETTING_SCOPE = 107, - - // ========================================================================== - // Tool Output Truncated Event Keys - // =========================================================================== - - // Logs the original length of the tool output. - GEMINI_CLI_TOOL_OUTPUT_TRUNCATED_ORIGINAL_LENGTH = 89, - - // Logs the truncated length of the tool output. - GEMINI_CLI_TOOL_OUTPUT_TRUNCATED_TRUNCATED_LENGTH = 90, - - // Logs the threshold at which the tool output was truncated. - GEMINI_CLI_TOOL_OUTPUT_TRUNCATED_THRESHOLD = 91, - - // Logs the number of lines the tool output was truncated to. - GEMINI_CLI_TOOL_OUTPUT_TRUNCATED_LINES = 92, - - // ========================================================================== - // Model Router Event Keys - // ========================================================================== - - // Logs the outcome of a model routing decision (e.g., which route/model was - // selected). - GEMINI_CLI_ROUTING_DECISION = 97, - - // Logs an event when the model router fails to make a decision or the chosen - // route fails. - GEMINI_CLI_ROUTING_FAILURE = 98, - - // Logs the latency in milliseconds for the router to make a decision. - GEMINI_CLI_ROUTING_LATENCY_MS = 99, - - // Logs a specific reason for a routing failure. - GEMINI_CLI_ROUTING_FAILURE_REASON = 100, - - // Logs the source of the decision. - GEMINI_CLI_ROUTING_DECISION_SOURCE = 101, - - // Logs an event when the user uses the /model command. - GEMINI_CLI_MODEL_SLASH_COMMAND = 108, -} diff --git a/packages/core/src/tools/smart-edit.test.ts b/packages/core/src/tools/smart-edit.test.ts deleted file mode 100644 index 9462ded91..000000000 --- a/packages/core/src/tools/smart-edit.test.ts +++ /dev/null @@ -1,674 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -/* eslint-disable @typescript-eslint/no-explicit-any */ - -const mockFixLLMEditWithInstruction = vi.hoisted(() => vi.fn()); -const mockGenerateJson = vi.hoisted(() => vi.fn()); -const mockOpenDiff = vi.hoisted(() => vi.fn()); - -import { IdeClient } from '../ide/ide-client.js'; - -vi.mock('../ide/ide-client.js', () => ({ - IdeClient: { - getInstance: vi.fn(), - }, -})); - -vi.mock('../utils/llm-edit-fixer.js', () => ({ - FixLLMEditWithInstruction: mockFixLLMEditWithInstruction, -})); - -vi.mock('../core/client.js', () => ({ - GeminiClient: vi.fn().mockImplementation(() => ({ - generateJson: mockGenerateJson, - })), -})); - -vi.mock('../utils/editor.js', () => ({ - openDiff: mockOpenDiff, -})); - -import { - describe, - it, - expect, - beforeEach, - afterEach, - vi, - type Mock, -} from 'vitest'; -import { - SmartEditTool, - type EditToolParams, - calculateReplacement, -} from './smart-edit.js'; -import { applyReplacement } from './edit.js'; -import { type FileDiff, ToolConfirmationOutcome } from './tools.js'; -import { ToolErrorType } from './tool-error.js'; -import path from 'node:path'; -import fs from 'node:fs'; -import os from 'node:os'; -import { ApprovalMode, type Config } from '../config/config.js'; -import { type Content, type Part, type SchemaUnion } from '@google/genai'; -import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js'; -import { StandardFileSystemService } from '../services/fileSystemService.js'; -import type { BaseLlmClient } from '../core/baseLlmClient.js'; - -describe('SmartEditTool', () => { - let tool: SmartEditTool; - let tempDir: string; - let rootDir: string; - let mockConfig: Config; - let geminiClient: any; - let baseLlmClient: BaseLlmClient; - - beforeEach(() => { - vi.restoreAllMocks(); - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'smart-edit-tool-test-')); - rootDir = path.join(tempDir, 'root'); - fs.mkdirSync(rootDir); - - geminiClient = { - generateJson: mockGenerateJson, - }; - - baseLlmClient = { - generateJson: mockGenerateJson, - } as unknown as BaseLlmClient; - - mockConfig = { - getGeminiClient: vi.fn().mockReturnValue(geminiClient), - getBaseLlmClient: vi.fn().mockReturnValue(baseLlmClient), - getTargetDir: () => rootDir, - getApprovalMode: vi.fn(), - setApprovalMode: vi.fn(), - getWorkspaceContext: () => createMockWorkspaceContext(rootDir), - getFileSystemService: () => new StandardFileSystemService(), - getIdeMode: () => false, - getApiKey: () => 'test-api-key', - getModel: () => 'test-model', - getSandbox: () => false, - getDebugMode: () => false, - getQuestion: () => undefined, - getFullContext: () => false, - getToolDiscoveryCommand: () => undefined, - getToolCallCommand: () => undefined, - getMcpServerCommand: () => undefined, - getMcpServers: () => undefined, - getUserAgent: () => 'test-agent', - getUserMemory: () => '', - setUserMemory: vi.fn(), - getGeminiMdFileCount: () => 0, - setGeminiMdFileCount: vi.fn(), - getToolRegistry: () => ({}) as any, - } as unknown as Config; - - (mockConfig.getApprovalMode as Mock).mockClear(); - (mockConfig.getApprovalMode as Mock).mockReturnValue(ApprovalMode.DEFAULT); - - mockFixLLMEditWithInstruction.mockReset(); - mockFixLLMEditWithInstruction.mockResolvedValue({ - noChangesRequired: false, - search: '', - replace: '', - explanation: 'LLM fix failed', - }); - - mockGenerateJson.mockReset(); - mockGenerateJson.mockImplementation( - async (contents: Content[], schema: SchemaUnion) => { - const userContent = contents.find((c: Content) => c.role === 'user'); - let promptText = ''; - if (userContent && userContent.parts) { - promptText = userContent.parts - .filter((p: Part) => typeof (p as any).text === 'string') - .map((p: Part) => (p as any).text) - .join('\n'); - } - const snippetMatch = promptText.match( - /Problematic target snippet:\n```\n([\s\S]*?)\n```/, - ); - const problematicSnippet = - snippetMatch && snippetMatch[1] ? snippetMatch[1] : ''; - - if (((schema as any).properties as any)?.corrected_target_snippet) { - return Promise.resolve({ - corrected_target_snippet: problematicSnippet, - }); - } - if (((schema as any).properties as any)?.corrected_new_string) { - const originalNewStringMatch = promptText.match( - /original_new_string \(what was intended to replace original_old_string\):\n```\n([\s\S]*?)\n```/, - ); - const originalNewString = - originalNewStringMatch && originalNewStringMatch[1] - ? originalNewStringMatch[1] - : ''; - return Promise.resolve({ corrected_new_string: originalNewString }); - } - return Promise.resolve({}); - }, - ); - - tool = new SmartEditTool(mockConfig); - }); - - afterEach(() => { - fs.rmSync(tempDir, { recursive: true, force: true }); - }); - - describe('applyReplacement', () => { - it('should return newString if isNewFile is true', () => { - expect(applyReplacement(null, 'old', 'new', true)).toBe('new'); - expect(applyReplacement('existing', 'old', 'new', true)).toBe('new'); - }); - - it('should replace oldString with newString in currentContent', () => { - expect(applyReplacement('hello old world old', 'old', 'new', false)).toBe( - 'hello new world new', - ); - }); - - it('should treat $ literally and not as replacement pattern', () => { - const current = 'regex end is $ and more'; - const oldStr = 'regex end is $'; - const newStr = 'regex end is $ and correct'; - const result = applyReplacement(current, oldStr, newStr, false); - expect(result).toBe('regex end is $ and correct and more'); - }); - - it("should treat $' literally and not as a replacement pattern", () => { - const current = 'foo'; - const oldStr = 'foo'; - const newStr = "bar$'baz"; - const result = applyReplacement(current, oldStr, newStr, false); - expect(result).toBe("bar$'baz"); - }); - }); - - describe('calculateReplacement', () => { - const abortSignal = new AbortController().signal; - - it('should perform an exact replacement', async () => { - const content = 'hello world'; - const result = await calculateReplacement({ - params: { - file_path: 'test.txt', - instruction: 'test', - old_string: 'world', - new_string: 'moon', - }, - currentContent: content, - abortSignal, - }); - expect(result.newContent).toBe('hello moon'); - expect(result.occurrences).toBe(1); - }); - - it('should perform a flexible, whitespace-insensitive replacement', async () => { - const content = ' hello\n world\n'; - const result = await calculateReplacement({ - params: { - file_path: 'test.txt', - instruction: 'test', - old_string: 'hello\nworld', - new_string: 'goodbye\nmoon', - }, - currentContent: content, - abortSignal, - }); - expect(result.newContent).toBe(' goodbye\n moon\n'); - expect(result.occurrences).toBe(1); - }); - - it('should return 0 occurrences if no match is found', async () => { - const content = 'hello world'; - const result = await calculateReplacement({ - params: { - file_path: 'test.txt', - instruction: 'test', - old_string: 'nomatch', - new_string: 'moon', - }, - currentContent: content, - abortSignal, - }); - expect(result.newContent).toBe(content); - expect(result.occurrences).toBe(0); - }); - - it('should perform a regex-based replacement for flexible intra-line whitespace', async () => { - // This case would fail with the previous exact and line-trimming flexible logic - // because the whitespace *within* the line is different. - const content = ' function myFunc( a, b ) {\n return a + b;\n }'; - const result = await calculateReplacement({ - params: { - file_path: 'test.js', - instruction: 'test', - old_string: 'function myFunc(a, b) {', // Note the normalized whitespace - new_string: 'const yourFunc = (a, b) => {', - }, - currentContent: content, - abortSignal, - }); - - // The indentation from the original line should be preserved and applied to the new string. - const expectedContent = - ' const yourFunc = (a, b) => {\n return a + b;\n }'; - expect(result.newContent).toBe(expectedContent); - expect(result.occurrences).toBe(1); - }); - }); - describe('correctPath', () => { - it('should correct a relative path if it is unambiguous', () => { - const testFile = 'unique.txt'; - fs.writeFileSync(path.join(rootDir, testFile), 'content'); - - const params: EditToolParams = { - file_path: testFile, - instruction: 'An instruction', - old_string: 'old', - new_string: 'new', - }; - - const validationResult = (tool as any).correctPath(params); - - expect(validationResult).toBeNull(); - expect(params.file_path).toBe(path.join(rootDir, testFile)); - }); - - it('should correct a partial relative path if it is unambiguous', () => { - const subDir = path.join(rootDir, 'sub'); - fs.mkdirSync(subDir); - const testFile = 'file.txt'; - const partialPath = path.join('sub', testFile); - const fullPath = path.join(subDir, testFile); - fs.writeFileSync(fullPath, 'content'); - - const params: EditToolParams = { - file_path: partialPath, - instruction: 'An instruction', - old_string: 'old', - new_string: 'new', - }; - - const validationResult = (tool as any).correctPath(params); - - expect(validationResult).toBeNull(); - expect(params.file_path).toBe(fullPath); - }); - - it('should return an error for a relative path that does not exist', () => { - const params: EditToolParams = { - file_path: 'test.txt', - instruction: 'An instruction', - old_string: 'old', - new_string: 'new', - }; - const result = (tool as any).correctPath(params); - expect(result).toMatch(/File not found for 'test.txt'/); - }); - - it('should return an error for an ambiguous path', () => { - const subDir1 = path.join(rootDir, 'module1'); - const subDir2 = path.join(rootDir, 'module2'); - fs.mkdirSync(subDir1, { recursive: true }); - fs.mkdirSync(subDir2, { recursive: true }); - - const ambiguousFile = 'component.ts'; - fs.writeFileSync(path.join(subDir1, ambiguousFile), 'content 1'); - fs.writeFileSync(path.join(subDir2, ambiguousFile), 'content 2'); - - const params: EditToolParams = { - file_path: ambiguousFile, - instruction: 'An instruction', - old_string: 'old', - new_string: 'new', - }; - - const validationResult = (tool as any).correctPath(params); - expect(validationResult).toMatch(/ambiguous and matches multiple files/); - }); - }); - - describe('validateToolParams', () => { - it('should return null for valid params', () => { - const params: EditToolParams = { - file_path: path.join(rootDir, 'test.txt'), - instruction: 'An instruction', - old_string: 'old', - new_string: 'new', - }; - expect(tool.validateToolParams(params)).toBeNull(); - }); - - it('should return an error if path is outside the workspace', () => { - const params: EditToolParams = { - file_path: path.join(os.tmpdir(), 'outside.txt'), - instruction: 'An instruction', - old_string: 'old', - new_string: 'new', - }; - expect(tool.validateToolParams(params)).toMatch( - /must be within one of the workspace directories/, - ); - }); - }); - - describe('execute', () => { - const testFile = 'execute_me.txt'; - let filePath: string; - - beforeEach(() => { - filePath = path.join(rootDir, testFile); - }); - - it('should reject when calculateEdit fails after an abort signal', async () => { - const params: EditToolParams = { - file_path: path.join(rootDir, 'abort-execute.txt'), - instruction: 'Abort during execute', - old_string: 'old', - new_string: 'new', - }; - - const invocation = tool.build(params); - const abortController = new AbortController(); - const abortError = new Error( - 'Abort requested during smart edit execution', - ); - - const calculateSpy = vi - .spyOn(invocation as any, 'calculateEdit') - .mockImplementation(async () => { - if (!abortController.signal.aborted) { - abortController.abort(); - } - throw abortError; - }); - - await expect(invocation.execute(abortController.signal)).rejects.toBe( - abortError, - ); - - calculateSpy.mockRestore(); - }); - - it('should edit an existing file and return diff with fileName', async () => { - const initialContent = 'This is some old text.'; - const newContent = 'This is some new text.'; - fs.writeFileSync(filePath, initialContent, 'utf8'); - const params: EditToolParams = { - file_path: filePath, - instruction: 'Replace old with new', - old_string: 'old', - new_string: 'new', - }; - - const invocation = tool.build(params); - const result = await invocation.execute(new AbortController().signal); - - expect(result.llmContent).toMatch(/Successfully modified file/); - expect(fs.readFileSync(filePath, 'utf8')).toBe(newContent); - const display = result.returnDisplay as FileDiff; - expect(display.fileDiff).toMatch(initialContent); - expect(display.fileDiff).toMatch(newContent); - expect(display.fileName).toBe(testFile); - }); - - it('should return error if old_string is not found in file', async () => { - fs.writeFileSync(filePath, 'Some content.', 'utf8'); - const params: EditToolParams = { - file_path: filePath, - instruction: 'Replace non-existent text', - old_string: 'nonexistent', - new_string: 'replacement', - }; - const invocation = tool.build(params); - const result = await invocation.execute(new AbortController().signal); - expect(result.llmContent).toMatch(/0 occurrences found for old_string/); - expect(result.returnDisplay).toMatch( - /Failed to edit, could not find the string to replace./, - ); - expect(mockFixLLMEditWithInstruction).toHaveBeenCalled(); - }); - - it('should succeed if FixLLMEditWithInstruction corrects the params', async () => { - const initialContent = 'This is some original text.'; - const finalContent = 'This is some brand new text.'; - fs.writeFileSync(filePath, initialContent, 'utf8'); - const params: EditToolParams = { - file_path: filePath, - instruction: 'Replace original with brand new', - old_string: 'original text that is slightly wrong', // This will fail first - new_string: 'brand new text', - }; - - mockFixLLMEditWithInstruction.mockResolvedValueOnce({ - noChangesRequired: false, - search: 'original text', // The corrected search string - replace: 'brand new text', - explanation: 'Corrected the search string to match the file content.', - }); - - const invocation = tool.build(params); - const result = await invocation.execute(new AbortController().signal); - - expect(result.error).toBeUndefined(); - expect(result.llmContent).toMatch(/Successfully modified file/); - expect(fs.readFileSync(filePath, 'utf8')).toBe(finalContent); - expect(mockFixLLMEditWithInstruction).toHaveBeenCalledTimes(1); - }); - - it('should return NO_CHANGE if FixLLMEditWithInstruction determines no changes are needed', async () => { - const initialContent = 'The price is $100.'; - fs.writeFileSync(filePath, initialContent, 'utf8'); - const params: EditToolParams = { - file_path: filePath, - instruction: 'Ensure the price is $100', - old_string: 'price is $50', // Incorrect old string - new_string: 'price is $100', - }; - - mockFixLLMEditWithInstruction.mockResolvedValueOnce({ - noChangesRequired: true, - search: '', - replace: '', - explanation: 'The price is already correctly set to $100.', - }); - - const invocation = tool.build(params); - const result = await invocation.execute(new AbortController().signal); - - expect(result.error?.type).toBe( - ToolErrorType.EDIT_NO_CHANGE_LLM_JUDGEMENT, - ); - expect(result.llmContent).toMatch( - /A secondary check by an LLM determined/, - ); - expect(fs.readFileSync(filePath, 'utf8')).toBe(initialContent); // File is unchanged - }); - - it('should preserve CRLF line endings when editing a file', async () => { - const initialContent = 'line one\r\nline two\r\n'; - const newContent = 'line one\r\nline three\r\n'; - fs.writeFileSync(filePath, initialContent, 'utf8'); - const params: EditToolParams = { - file_path: filePath, - instruction: 'Replace two with three', - old_string: 'line two', - new_string: 'line three', - }; - - const invocation = tool.build(params); - await invocation.execute(new AbortController().signal); - - const finalContent = fs.readFileSync(filePath, 'utf8'); - expect(finalContent).toBe(newContent); - }); - - it('should create a new file with CRLF line endings if new_string has them', async () => { - const newContentWithCRLF = 'new line one\r\nnew line two\r\n'; - const params: EditToolParams = { - file_path: filePath, - instruction: 'Create a new file', - old_string: '', - new_string: newContentWithCRLF, - }; - - const invocation = tool.build(params); - await invocation.execute(new AbortController().signal); - - const finalContent = fs.readFileSync(filePath, 'utf8'); - expect(finalContent).toBe(newContentWithCRLF); - }); - }); - - describe('Error Scenarios', () => { - const testFile = 'error_test.txt'; - let filePath: string; - - beforeEach(() => { - filePath = path.join(rootDir, testFile); - }); - - it('should return FILE_NOT_FOUND error', async () => { - const params: EditToolParams = { - file_path: filePath, - instruction: 'test', - old_string: 'any', - new_string: 'new', - }; - const invocation = tool.build(params); - const result = await invocation.execute(new AbortController().signal); - expect(result.error?.type).toBe(ToolErrorType.FILE_NOT_FOUND); - }); - - it('should return ATTEMPT_TO_CREATE_EXISTING_FILE error', async () => { - fs.writeFileSync(filePath, 'existing content', 'utf8'); - const params: EditToolParams = { - file_path: filePath, - instruction: 'test', - old_string: '', - new_string: 'new content', - }; - const invocation = tool.build(params); - const result = await invocation.execute(new AbortController().signal); - expect(result.error?.type).toBe( - ToolErrorType.ATTEMPT_TO_CREATE_EXISTING_FILE, - ); - }); - - it('should return NO_OCCURRENCE_FOUND error', async () => { - fs.writeFileSync(filePath, 'content', 'utf8'); - const params: EditToolParams = { - file_path: filePath, - instruction: 'test', - old_string: 'not-found', - new_string: 'new', - }; - const invocation = tool.build(params); - const result = await invocation.execute(new AbortController().signal); - expect(result.error?.type).toBe(ToolErrorType.EDIT_NO_OCCURRENCE_FOUND); - }); - - it('should return EXPECTED_OCCURRENCE_MISMATCH error', async () => { - fs.writeFileSync(filePath, 'one one two', 'utf8'); - const params: EditToolParams = { - file_path: filePath, - instruction: 'test', - old_string: 'one', - new_string: 'new', - }; - const invocation = tool.build(params); - const result = await invocation.execute(new AbortController().signal); - expect(result.error?.type).toBe( - ToolErrorType.EDIT_EXPECTED_OCCURRENCE_MISMATCH, - ); - }); - }); - - describe('IDE mode', () => { - const testFile = 'edit_me.txt'; - let filePath: string; - let ideClient: any; - - beforeEach(() => { - filePath = path.join(rootDir, testFile); - ideClient = { - openDiff: vi.fn(), - isDiffingEnabled: vi.fn().mockReturnValue(true), - }; - vi.mocked(IdeClient.getInstance).mockResolvedValue(ideClient); - (mockConfig as any).getIdeMode = () => true; - }); - - it('should call ideClient.openDiff and update params on confirmation', async () => { - const initialContent = 'some old content here'; - const newContent = 'some new content here'; - const modifiedContent = 'some modified content here'; - fs.writeFileSync(filePath, initialContent); - const params: EditToolParams = { - file_path: filePath, - instruction: 'test', - old_string: 'old', - new_string: 'new', - }; - - ideClient.openDiff.mockResolvedValueOnce({ - status: 'accepted', - content: modifiedContent, - }); - - const invocation = tool.build(params); - const confirmation = await invocation.shouldConfirmExecute( - new AbortController().signal, - ); - - expect(ideClient.openDiff).toHaveBeenCalledWith(filePath, newContent); - - if (confirmation && 'onConfirm' in confirmation) { - await confirmation.onConfirm(ToolConfirmationOutcome.ProceedOnce); - } - - expect(params.old_string).toBe(initialContent); - expect(params.new_string).toBe(modifiedContent); - }); - }); - - describe('shouldConfirmExecute', () => { - it('should rethrow calculateEdit errors when the abort signal is triggered', async () => { - const filePath = path.join(rootDir, 'abort-confirmation.txt'); - const params: EditToolParams = { - file_path: filePath, - instruction: 'Abort during confirmation', - old_string: 'old', - new_string: 'new', - }; - - const invocation = tool.build(params); - const abortController = new AbortController(); - const abortError = new Error( - 'Abort requested during smart edit confirmation', - ); - - const calculateSpy = vi - .spyOn(invocation as any, 'calculateEdit') - .mockImplementation(async () => { - if (!abortController.signal.aborted) { - abortController.abort(); - } - throw abortError; - }); - - await expect( - invocation.shouldConfirmExecute(abortController.signal), - ).rejects.toBe(abortError); - - calculateSpy.mockRestore(); - }); - }); -}); diff --git a/packages/core/src/tools/smart-edit.ts b/packages/core/src/tools/smart-edit.ts deleted file mode 100644 index ae9d61fbf..000000000 --- a/packages/core/src/tools/smart-edit.ts +++ /dev/null @@ -1,965 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import * as Diff from 'diff'; -import { - BaseDeclarativeTool, - Kind, - type ToolCallConfirmationDetails, - ToolConfirmationOutcome, - type ToolEditConfirmationDetails, - type ToolInvocation, - type ToolLocation, - type ToolResult, - type ToolResultDisplay, -} from './tools.js'; -import { ToolNames } from './tool-names.js'; -import { ToolErrorType } from './tool-error.js'; -import { makeRelative, shortenPath } from '../utils/paths.js'; -import { isNodeError } from '../utils/errors.js'; -import { type Config, ApprovalMode } from '../config/config.js'; -import { DEFAULT_DIFF_OPTIONS, getDiffStat } from './diffOptions.js'; -import { ReadFileTool } from './read-file.js'; -import { - type ModifiableDeclarativeTool, - type ModifyContext, -} from './modifiable-tool.js'; -import { IdeClient } from '../ide/ide-client.js'; -import { FixLLMEditWithInstruction } from '../utils/llm-edit-fixer.js'; -import { applyReplacement } from './edit.js'; -import { safeLiteralReplace } from '../utils/textUtils.js'; - -interface ReplacementContext { - params: EditToolParams; - currentContent: string; - abortSignal: AbortSignal; -} - -interface ReplacementResult { - newContent: string; - occurrences: number; - finalOldString: string; - finalNewString: string; -} - -function restoreTrailingNewline( - originalContent: string, - modifiedContent: string, -): string { - const hadTrailingNewline = originalContent.endsWith('\n'); - if (hadTrailingNewline && !modifiedContent.endsWith('\n')) { - return modifiedContent + '\n'; - } else if (!hadTrailingNewline && modifiedContent.endsWith('\n')) { - return modifiedContent.replace(/\n$/, ''); - } - return modifiedContent; -} - -/** - * Escapes characters with special meaning in regular expressions. - * @param str The string to escape. - * @returns The escaped string. - */ -function escapeRegex(str: string): string { - return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string -} - -async function calculateExactReplacement( - context: ReplacementContext, -): Promise { - const { currentContent, params } = context; - const { old_string, new_string } = params; - - const normalizedCode = currentContent; - const normalizedSearch = old_string.replace(/\r\n/g, '\n'); - const normalizedReplace = new_string.replace(/\r\n/g, '\n'); - - const exactOccurrences = normalizedCode.split(normalizedSearch).length - 1; - if (exactOccurrences > 0) { - let modifiedCode = safeLiteralReplace( - normalizedCode, - normalizedSearch, - normalizedReplace, - ); - modifiedCode = restoreTrailingNewline(currentContent, modifiedCode); - return { - newContent: modifiedCode, - occurrences: exactOccurrences, - finalOldString: normalizedSearch, - finalNewString: normalizedReplace, - }; - } - - return null; -} - -async function calculateFlexibleReplacement( - context: ReplacementContext, -): Promise { - const { currentContent, params } = context; - const { old_string, new_string } = params; - - const normalizedCode = currentContent; - const normalizedSearch = old_string.replace(/\r\n/g, '\n'); - const normalizedReplace = new_string.replace(/\r\n/g, '\n'); - - const sourceLines = normalizedCode.match(/.*(?:\n|$)/g)?.slice(0, -1) ?? []; - const searchLinesStripped = normalizedSearch - .split('\n') - .map((line: string) => line.trim()); - const replaceLines = normalizedReplace.split('\n'); - - let flexibleOccurrences = 0; - let i = 0; - while (i <= sourceLines.length - searchLinesStripped.length) { - const window = sourceLines.slice(i, i + searchLinesStripped.length); - const windowStripped = window.map((line: string) => line.trim()); - const isMatch = windowStripped.every( - (line: string, index: number) => line === searchLinesStripped[index], - ); - - if (isMatch) { - flexibleOccurrences++; - const firstLineInMatch = window[0]; - const indentationMatch = firstLineInMatch.match(/^(\s*)/); - const indentation = indentationMatch ? indentationMatch[1] : ''; - const newBlockWithIndent = replaceLines.map( - (line: string) => `${indentation}${line}`, - ); - sourceLines.splice( - i, - searchLinesStripped.length, - newBlockWithIndent.join('\n'), - ); - i += replaceLines.length; - } else { - i++; - } - } - - if (flexibleOccurrences > 0) { - let modifiedCode = sourceLines.join(''); - modifiedCode = restoreTrailingNewline(currentContent, modifiedCode); - return { - newContent: modifiedCode, - occurrences: flexibleOccurrences, - finalOldString: normalizedSearch, - finalNewString: normalizedReplace, - }; - } - - return null; -} - -async function calculateRegexReplacement( - context: ReplacementContext, -): Promise { - const { currentContent, params } = context; - const { old_string, new_string } = params; - - // Normalize line endings for consistent processing. - const normalizedSearch = old_string.replace(/\r\n/g, '\n'); - const normalizedReplace = new_string.replace(/\r\n/g, '\n'); - - // This logic is ported from your Python implementation. - // It builds a flexible, multi-line regex from a search string. - const delimiters = ['(', ')', ':', '[', ']', '{', '}', '>', '<', '=']; - - let processedString = normalizedSearch; - for (const delim of delimiters) { - processedString = processedString.split(delim).join(` ${delim} `); - } - - // Split by any whitespace and remove empty strings. - const tokens = processedString.split(/\s+/).filter(Boolean); - - if (tokens.length === 0) { - return null; - } - - const escapedTokens = tokens.map(escapeRegex); - // Join tokens with `\s*` to allow for flexible whitespace between them. - const pattern = escapedTokens.join('\\s*'); - - // The final pattern captures leading whitespace (indentation) and then matches the token pattern. - // 'm' flag enables multi-line mode, so '^' matches the start of any line. - const finalPattern = `^(\\s*)${pattern}`; - const flexibleRegex = new RegExp(finalPattern, 'm'); - - const match = flexibleRegex.exec(currentContent); - - if (!match) { - return null; - } - - const indentation = match[1] || ''; - const newLines = normalizedReplace.split('\n'); - const newBlockWithIndent = newLines - .map((line) => `${indentation}${line}`) - .join('\n'); - - // Use replace with the regex to substitute the matched content. - // Since the regex doesn't have the 'g' flag, it will only replace the first occurrence. - const modifiedCode = currentContent.replace( - flexibleRegex, - newBlockWithIndent, - ); - - return { - newContent: restoreTrailingNewline(currentContent, modifiedCode), - occurrences: 1, // This method is designed to find and replace only the first occurrence. - finalOldString: normalizedSearch, - finalNewString: normalizedReplace, - }; -} - -/** - * Detects the line ending style of a string. - * @param content The string content to analyze. - * @returns '\r\n' for Windows-style, '\n' for Unix-style. - */ -function detectLineEnding(content: string): '\r\n' | '\n' { - // If a Carriage Return is found, assume Windows-style endings. - // This is a simple but effective heuristic. - return content.includes('\r\n') ? '\r\n' : '\n'; -} - -export async function calculateReplacement( - context: ReplacementContext, -): Promise { - const { currentContent, params } = context; - const { old_string, new_string } = params; - const normalizedSearch = old_string.replace(/\r\n/g, '\n'); - const normalizedReplace = new_string.replace(/\r\n/g, '\n'); - - if (normalizedSearch === '') { - return { - newContent: currentContent, - occurrences: 0, - finalOldString: normalizedSearch, - finalNewString: normalizedReplace, - }; - } - - const exactResult = await calculateExactReplacement(context); - if (exactResult) { - return exactResult; - } - - const flexibleResult = await calculateFlexibleReplacement(context); - if (flexibleResult) { - return flexibleResult; - } - - const regexResult = await calculateRegexReplacement(context); - if (regexResult) { - return regexResult; - } - - return { - newContent: currentContent, - occurrences: 0, - finalOldString: normalizedSearch, - finalNewString: normalizedReplace, - }; -} - -export function getErrorReplaceResult( - params: EditToolParams, - occurrences: number, - expectedReplacements: number, - finalOldString: string, - finalNewString: string, -) { - let error: { display: string; raw: string; type: ToolErrorType } | undefined = - undefined; - if (occurrences === 0) { - error = { - display: `Failed to edit, could not find the string to replace.`, - raw: `Failed to edit, 0 occurrences found for old_string (${finalOldString}). Original old_string was (${params.old_string}) in ${params.file_path}. No edits made. The exact text in old_string was not found. Ensure you're not escaping content incorrectly and check whitespace, indentation, and context. Use ${ReadFileTool.Name} tool to verify.`, - type: ToolErrorType.EDIT_NO_OCCURRENCE_FOUND, - }; - } else if (occurrences !== expectedReplacements) { - const occurrenceTerm = - expectedReplacements === 1 ? 'occurrence' : 'occurrences'; - - error = { - display: `Failed to edit, expected ${expectedReplacements} ${occurrenceTerm} but found ${occurrences}.`, - raw: `Failed to edit, Expected ${expectedReplacements} ${occurrenceTerm} but found ${occurrences} for old_string in file: ${params.file_path}`, - type: ToolErrorType.EDIT_EXPECTED_OCCURRENCE_MISMATCH, - }; - } else if (finalOldString === finalNewString) { - error = { - display: `No changes to apply. The old_string and new_string are identical.`, - raw: `No changes to apply. The old_string and new_string are identical in file: ${params.file_path}`, - type: ToolErrorType.EDIT_NO_CHANGE, - }; - } - return error; -} - -/** - * Parameters for the Edit tool - */ -export interface EditToolParams { - /** - * The absolute path to the file to modify - */ - file_path: string; - - /** - * The text to replace - */ - old_string: string; - - /** - * The text to replace it with - */ - new_string: string; - - /** - * The instruction for what needs to be done. - */ - instruction: string; - - /** - * Whether the edit was modified manually by the user. - */ - modified_by_user?: boolean; - - /** - * Initially proposed string. - */ - ai_proposed_string?: string; -} - -interface CalculatedEdit { - currentContent: string | null; - newContent: string; - occurrences: number; - error?: { display: string; raw: string; type: ToolErrorType }; - isNewFile: boolean; - originalLineEnding: '\r\n' | '\n'; -} - -class EditToolInvocation implements ToolInvocation { - constructor( - private readonly config: Config, - public params: EditToolParams, - ) {} - - toolLocations(): ToolLocation[] { - return [{ path: this.params.file_path }]; - } - - private async attemptSelfCorrection( - params: EditToolParams, - currentContent: string, - initialError: { display: string; raw: string; type: ToolErrorType }, - abortSignal: AbortSignal, - originalLineEnding: '\r\n' | '\n', - ): Promise { - const fixedEdit = await FixLLMEditWithInstruction( - params.instruction, - params.old_string, - params.new_string, - initialError.raw, - currentContent, - this.config.getBaseLlmClient(), - abortSignal, - ); - - if (fixedEdit.noChangesRequired) { - return { - currentContent, - newContent: currentContent, - occurrences: 0, - isNewFile: false, - error: { - display: `No changes required. The file already meets the specified conditions.`, - raw: `A secondary check by an LLM determined that no changes were necessary to fulfill the instruction. Explanation: ${fixedEdit.explanation}. Original error with the parameters given: ${initialError.raw}`, - type: ToolErrorType.EDIT_NO_CHANGE_LLM_JUDGEMENT, - }, - originalLineEnding, - }; - } - - const secondAttemptResult = await calculateReplacement({ - params: { - ...params, - old_string: fixedEdit.search, - new_string: fixedEdit.replace, - }, - currentContent, - abortSignal, - }); - - const secondError = getErrorReplaceResult( - params, - secondAttemptResult.occurrences, - 1, // expectedReplacements is always 1 for smart_edit - secondAttemptResult.finalOldString, - secondAttemptResult.finalNewString, - ); - - if (secondError) { - // The fix failed, return the original error - return { - currentContent, - newContent: currentContent, - occurrences: 0, - isNewFile: false, - error: initialError, - originalLineEnding, - }; - } - - return { - currentContent, - newContent: secondAttemptResult.newContent, - occurrences: secondAttemptResult.occurrences, - isNewFile: false, - error: undefined, - originalLineEnding, - }; - } - - /** - * Calculates the potential outcome of an edit operation. - * @param params Parameters for the edit operation - * @returns An object describing the potential edit outcome - * @throws File system errors if reading the file fails unexpectedly (e.g., permissions) - */ - private async calculateEdit( - params: EditToolParams, - abortSignal: AbortSignal, - ): Promise { - const expectedReplacements = 1; - let currentContent: string | null = null; - let fileExists = false; - let originalLineEnding: '\r\n' | '\n' = '\n'; // Default for new files - - try { - currentContent = await this.config - .getFileSystemService() - .readTextFile(params.file_path); - originalLineEnding = detectLineEnding(currentContent); - currentContent = currentContent.replace(/\r\n/g, '\n'); - fileExists = true; - } catch (err: unknown) { - if (!isNodeError(err) || err.code !== 'ENOENT') { - throw err; - } - fileExists = false; - } - - const isNewFile = params.old_string === '' && !fileExists; - - if (isNewFile) { - return { - currentContent, - newContent: params.new_string, - occurrences: 1, - isNewFile: true, - error: undefined, - originalLineEnding, - }; - } - - // after this point, it's not a new file/edit - if (!fileExists) { - return { - currentContent, - newContent: '', - occurrences: 0, - isNewFile: false, - error: { - display: `File not found. Cannot apply edit. Use an empty old_string to create a new file.`, - raw: `File not found: ${params.file_path}`, - type: ToolErrorType.FILE_NOT_FOUND, - }, - originalLineEnding, - }; - } - - if (currentContent === null) { - return { - currentContent, - newContent: '', - occurrences: 0, - isNewFile: false, - error: { - display: `Failed to read content of file.`, - raw: `Failed to read content of existing file: ${params.file_path}`, - type: ToolErrorType.READ_CONTENT_FAILURE, - }, - originalLineEnding, - }; - } - - if (params.old_string === '') { - return { - currentContent, - newContent: currentContent, - occurrences: 0, - isNewFile: false, - error: { - display: `Failed to edit. Attempted to create a file that already exists.`, - raw: `File already exists, cannot create: ${params.file_path}`, - type: ToolErrorType.ATTEMPT_TO_CREATE_EXISTING_FILE, - }, - originalLineEnding, - }; - } - - const replacementResult = await calculateReplacement({ - params, - currentContent, - abortSignal, - }); - - const initialError = getErrorReplaceResult( - params, - replacementResult.occurrences, - expectedReplacements, - replacementResult.finalOldString, - replacementResult.finalNewString, - ); - - if (!initialError) { - return { - currentContent, - newContent: replacementResult.newContent, - occurrences: replacementResult.occurrences, - isNewFile: false, - error: undefined, - originalLineEnding, - }; - } - - // If there was an error, try to self-correct. - return this.attemptSelfCorrection( - params, - currentContent, - initialError, - abortSignal, - originalLineEnding, - ); - } - - /** - * Handles the confirmation prompt for the Edit tool in the CLI. - * It needs to calculate the diff to show the user. - */ - async shouldConfirmExecute( - abortSignal: AbortSignal, - ): Promise { - if (this.config.getApprovalMode() === ApprovalMode.AUTO_EDIT) { - return false; - } - - let editData: CalculatedEdit; - try { - editData = await this.calculateEdit(this.params, abortSignal); - } catch (error) { - if (abortSignal.aborted) { - throw error; - } - const errorMsg = error instanceof Error ? error.message : String(error); - console.log(`Error preparing edit: ${errorMsg}`); - return false; - } - - if (editData.error) { - console.log(`Error: ${editData.error.display}`); - return false; - } - - const fileName = path.basename(this.params.file_path); - const fileDiff = Diff.createPatch( - fileName, - editData.currentContent ?? '', - editData.newContent, - 'Current', - 'Proposed', - DEFAULT_DIFF_OPTIONS, - ); - const ideClient = await IdeClient.getInstance(); - const ideConfirmation = - this.config.getIdeMode() && ideClient.isDiffingEnabled() - ? ideClient.openDiff(this.params.file_path, editData.newContent) - : undefined; - - const confirmationDetails: ToolEditConfirmationDetails = { - type: 'edit', - title: `Confirm Edit: ${shortenPath(makeRelative(this.params.file_path, this.config.getTargetDir()))}`, - fileName, - filePath: this.params.file_path, - fileDiff, - originalContent: editData.currentContent, - newContent: editData.newContent, - onConfirm: async (outcome: ToolConfirmationOutcome) => { - if (outcome === ToolConfirmationOutcome.ProceedAlways) { - this.config.setApprovalMode(ApprovalMode.AUTO_EDIT); - } - - if (ideConfirmation) { - const result = await ideConfirmation; - if (result.status === 'accepted' && result.content) { - // TODO(chrstn): See https://github.com/google-gemini/gemini-cli/pull/5618#discussion_r2255413084 - // for info on a possible race condition where the file is modified on disk while being edited. - this.params.old_string = editData.currentContent ?? ''; - this.params.new_string = result.content; - } - } - }, - ideConfirmation, - }; - return confirmationDetails; - } - - getDescription(): string { - const relativePath = makeRelative( - this.params.file_path, - this.config.getTargetDir(), - ); - if (this.params.old_string === '') { - return `Create ${shortenPath(relativePath)}`; - } - - const oldStringSnippet = - this.params.old_string.split('\n')[0].substring(0, 30) + - (this.params.old_string.length > 30 ? '...' : ''); - const newStringSnippet = - this.params.new_string.split('\n')[0].substring(0, 30) + - (this.params.new_string.length > 30 ? '...' : ''); - - if (this.params.old_string === this.params.new_string) { - return `No file changes to ${shortenPath(relativePath)}`; - } - return `${shortenPath(relativePath)}: ${oldStringSnippet} => ${newStringSnippet}`; - } - - /** - * Executes the edit operation with the given parameters. - * @param params Parameters for the edit operation - * @returns Result of the edit operation - */ - async execute(signal: AbortSignal): Promise { - let editData: CalculatedEdit; - try { - editData = await this.calculateEdit(this.params, signal); - } catch (error) { - if (signal.aborted) { - throw error; - } - const errorMsg = error instanceof Error ? error.message : String(error); - return { - llmContent: `Error preparing edit: ${errorMsg}`, - returnDisplay: `Error preparing edit: ${errorMsg}`, - error: { - message: errorMsg, - type: ToolErrorType.EDIT_PREPARATION_FAILURE, - }, - }; - } - - if (editData.error) { - return { - llmContent: editData.error.raw, - returnDisplay: `Error: ${editData.error.display}`, - error: { - message: editData.error.raw, - type: editData.error.type, - }, - }; - } - - try { - this.ensureParentDirectoriesExist(this.params.file_path); - let finalContent = editData.newContent; - - // Restore original line endings if they were CRLF - if (!editData.isNewFile && editData.originalLineEnding === '\r\n') { - finalContent = finalContent.replace(/\n/g, '\r\n'); - } - await this.config - .getFileSystemService() - .writeTextFile(this.params.file_path, finalContent); - - let displayResult: ToolResultDisplay; - if (editData.isNewFile) { - displayResult = `Created ${shortenPath(makeRelative(this.params.file_path, this.config.getTargetDir()))}`; - } else { - // Generate diff for display, even though core logic doesn't technically need it - // The CLI wrapper will use this part of the ToolResult - const fileName = path.basename(this.params.file_path); - const fileDiff = Diff.createPatch( - fileName, - editData.currentContent ?? '', // Should not be null here if not isNewFile - editData.newContent, - 'Current', - 'Proposed', - DEFAULT_DIFF_OPTIONS, - ); - const originallyProposedContent = - this.params.ai_proposed_string || this.params.new_string; - const diffStat = getDiffStat( - fileName, - editData.currentContent ?? '', - originallyProposedContent, - this.params.new_string, - ); - displayResult = { - fileDiff, - fileName, - originalContent: editData.currentContent, - newContent: editData.newContent, - diffStat, - }; - } - - const llmSuccessMessageParts = [ - editData.isNewFile - ? `Created new file: ${this.params.file_path} with provided content.` - : `Successfully modified file: ${this.params.file_path} (${editData.occurrences} replacements).`, - ]; - if (this.params.modified_by_user) { - llmSuccessMessageParts.push( - `User modified the \`new_string\` content to be: ${this.params.new_string}.`, - ); - } - - return { - llmContent: llmSuccessMessageParts.join(' '), - returnDisplay: displayResult, - }; - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - return { - llmContent: `Error executing edit: ${errorMsg}`, - returnDisplay: `Error writing file: ${errorMsg}`, - error: { - message: errorMsg, - type: ToolErrorType.FILE_WRITE_FAILURE, - }, - }; - } - } - - /** - * Creates parent directories if they don't exist - */ - private ensureParentDirectoriesExist(filePath: string): void { - const dirName = path.dirname(filePath); - if (!fs.existsSync(dirName)) { - fs.mkdirSync(dirName, { recursive: true }); - } - } -} - -/** - * Implementation of the Edit tool logic - */ -export class SmartEditTool - extends BaseDeclarativeTool - implements ModifiableDeclarativeTool -{ - static readonly Name = ToolNames.EDIT; - - constructor(private readonly config: Config) { - super( - SmartEditTool.Name, - 'Edit', - `Replaces text within a file. Replaces a single occurrence. This tool requires providing significant context around the change to ensure precise targeting. Always use the ${ReadFileTool.Name} tool to examine the file's current content before attempting a text replacement. - - The user has the ability to modify the \`new_string\` content. If modified, this will be stated in the response. - - Expectation for required parameters: - 1. \`file_path\` MUST be an absolute path; otherwise an error will be thrown. - 2. \`old_string\` MUST be the exact literal text to replace (including all whitespace, indentation, newlines, and surrounding code etc.). - 3. \`new_string\` MUST be the exact literal text to replace \`old_string\` with (also including all whitespace, indentation, newlines, and surrounding code etc.). Ensure the resulting code is correct and idiomatic and that \`old_string\` and \`new_string\` are different. - 4. \`instruction\` is the detailed instruction of what needs to be changed. It is important to Make it specific and detailed so developers or large language models can understand what needs to be changed and perform the changes on their own if necessary. - 5. NEVER escape \`old_string\` or \`new_string\`, that would break the exact literal text requirement. - **Important:** If ANY of the above are not satisfied, the tool will fail. CRITICAL for \`old_string\`: Must uniquely identify the single instance to change. Include at least 3 lines of context BEFORE and AFTER the target text, matching whitespace and indentation precisely. If this string matches multiple locations, or does not match exactly, the tool will fail. - 6. Prefer to break down complex and long changes into multiple smaller atomic calls to this tool. Always check the content of the file after changes or not finding a string to match. - **Multiple replacements:** If there are multiple and ambiguous occurences of the \`old_string\` in the file, the tool will also fail.`, - Kind.Edit, - { - properties: { - file_path: { - description: - "The absolute path to the file to modify. Must start with '/'.", - type: 'string', - }, - instruction: { - description: `A clear, semantic instruction for the code change, acting as a high-quality prompt for an expert LLM assistant. It must be self-contained and explain the goal of the change. - -A good instruction should concisely answer: -1. WHY is the change needed? (e.g., "To fix a bug where users can be null...") -2. WHERE should the change happen? (e.g., "...in the 'renderUserProfile' function...") -3. WHAT is the high-level change? (e.g., "...add a null check for the 'user' object...") -4. WHAT is the desired outcome? (e.g., "...so that it displays a loading spinner instead of crashing.") - -**GOOD Example:** "In the 'calculateTotal' function, correct the sales tax calculation by updating the 'taxRate' constant from 0.05 to 0.075 to reflect the new regional tax laws." - -**BAD Examples:** -- "Change the text." (Too vague) -- "Fix the bug." (Doesn't explain the bug or the fix) -- "Replace the line with this new line." (Brittle, just repeats the other parameters) -`, - type: 'string', - }, - old_string: { - description: - 'The exact literal text to replace, preferably unescaped. Include at least 3 lines of context BEFORE and AFTER the target text, matching whitespace and indentation precisely. If this string is not the exact literal text (i.e. you escaped it) or does not match exactly, the tool will fail.', - type: 'string', - }, - new_string: { - description: - 'The exact literal text to replace `old_string` with, preferably unescaped. Provide the EXACT text. Ensure the resulting code is correct and idiomatic.', - type: 'string', - }, - }, - required: ['file_path', 'instruction', 'old_string', 'new_string'], - type: 'object', - }, - ); - } - - /** - * Quickly checks if the file path can be resolved directly against the workspace root. - * @param filePath The relative file path to check. - * @returns The absolute path if the file exists, otherwise null. - */ - private findDirectPath(filePath: string): string | null { - const directPath = path.join(this.config.getTargetDir(), filePath); - return fs.existsSync(directPath) ? directPath : null; - } - - /** - * Searches for a file across all configured workspace directories. - * @param filePath The file path (can be partial) to search for. - * @returns A list of absolute paths for all matching files found. - */ - private findAmbiguousPaths(filePath: string): string[] { - const workspaceContext = this.config.getWorkspaceContext(); - const fileSystem = this.config.getFileSystemService(); - const searchPaths = workspaceContext.getDirectories(); - return fileSystem.findFiles(filePath, searchPaths); - } - - /** - * Attempts to correct a relative file path to an absolute path. - * This function modifies `params.file_path` in place if successful. - * @param params The tool parameters containing the file_path to correct. - * @returns An error message string if correction fails, otherwise null. - */ - private correctPath(params: EditToolParams): string | null { - const directPath = this.findDirectPath(params.file_path); - if (directPath) { - params.file_path = directPath; - return null; - } - - const foundFiles = this.findAmbiguousPaths(params.file_path); - - if (foundFiles.length === 0) { - return `File not found for '${params.file_path}' and path is not absolute.`; - } - - if (foundFiles.length > 1) { - return ( - `The file path '${params.file_path}' is too ambiguous and matches multiple files. ` + - `Please provide a more specific path. Matches: ${foundFiles.join(', ')}` - ); - } - - params.file_path = foundFiles[0]; - return null; - } - - /** - * Validates the parameters for the Edit tool - * @param params Parameters to validate - * @returns Error message string or null if valid - */ - protected override validateToolParamValues( - params: EditToolParams, - ): string | null { - if (!params.file_path) { - return "The 'file_path' parameter must be non-empty."; - } - - if (!path.isAbsolute(params.file_path)) { - // Attempt to auto-correct to an absolute path - const error = this.correctPath(params); - if (error) return error; - } - - const workspaceContext = this.config.getWorkspaceContext(); - if (!workspaceContext.isPathWithinWorkspace(params.file_path)) { - const directories = workspaceContext.getDirectories(); - return `File path must be within one of the workspace directories: ${directories.join(', ')}`; - } - - return null; - } - - protected createInvocation( - params: EditToolParams, - ): ToolInvocation { - return new EditToolInvocation(this.config, params); - } - - getModifyContext(_: AbortSignal): ModifyContext { - return { - getFilePath: (params: EditToolParams) => params.file_path, - getCurrentContent: async (params: EditToolParams): Promise => { - try { - return this.config - .getFileSystemService() - .readTextFile(params.file_path); - } catch (err) { - if (!isNodeError(err) || err.code !== 'ENOENT') throw err; - return ''; - } - }, - getProposedContent: async (params: EditToolParams): Promise => { - try { - const currentContent = await this.config - .getFileSystemService() - .readTextFile(params.file_path); - return applyReplacement( - currentContent, - params.old_string, - params.new_string, - params.old_string === '' && currentContent === '', - ); - } catch (err) { - if (!isNodeError(err) || err.code !== 'ENOENT') throw err; - return ''; - } - }, - createUpdatedParams: ( - oldContent: string, - modifiedProposedContent: string, - originalParams: EditToolParams, - ): EditToolParams => { - const content = originalParams.new_string; - return { - ...originalParams, - ai_proposed_string: content, - old_string: oldContent, - new_string: modifiedProposedContent, - modified_by_user: true, - }; - }, - }; - } -} diff --git a/packages/core/src/utils/llm-edit-fixer.test.ts b/packages/core/src/utils/llm-edit-fixer.test.ts deleted file mode 100644 index 222bf2289..000000000 --- a/packages/core/src/utils/llm-edit-fixer.test.ts +++ /dev/null @@ -1,322 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { - FixLLMEditWithInstruction, - resetLlmEditFixerCaches_TEST_ONLY, - type SearchReplaceEdit, -} from './llm-edit-fixer.js'; -import { promptIdContext } from './promptIdContext.js'; -import type { BaseLlmClient } from '../core/baseLlmClient.js'; - -// Mock the BaseLlmClient -const mockGenerateJson = vi.fn(); -const mockBaseLlmClient = { - generateJson: mockGenerateJson, -} as unknown as BaseLlmClient; - -describe('FixLLMEditWithInstruction', () => { - const instruction = 'Replace the title'; - const old_string = '

Old Title

'; - const new_string = '

New Title

'; - const error = 'String not found'; - const current_content = '

Old Title

'; - const abortController = new AbortController(); - const abortSignal = abortController.signal; - - beforeEach(() => { - vi.clearAllMocks(); - resetLlmEditFixerCaches_TEST_ONLY(); // Ensure cache is cleared before each test - }); - - afterEach(() => { - vi.useRealTimers(); // Reset timers after each test - }); - - const mockApiResponse: SearchReplaceEdit = { - search: '

Old Title

', - replace: '

New Title

', - noChangesRequired: false, - explanation: 'The original search was correct.', - }; - - it('should use the promptId from the AsyncLocalStorage context when available', async () => { - const testPromptId = 'test-prompt-id-12345'; - mockGenerateJson.mockResolvedValue(mockApiResponse); - - await promptIdContext.run(testPromptId, async () => { - await FixLLMEditWithInstruction( - instruction, - old_string, - new_string, - error, - current_content, - mockBaseLlmClient, - abortSignal, - ); - }); - - // Verify that generateJson was called with the promptId from the context - expect(mockGenerateJson).toHaveBeenCalledTimes(1); - expect(mockGenerateJson).toHaveBeenCalledWith( - expect.objectContaining({ - promptId: testPromptId, - }), - ); - }); - - it('should generate and use a fallback promptId when context is not available', async () => { - mockGenerateJson.mockResolvedValue(mockApiResponse); - const consoleWarnSpy = vi - .spyOn(console, 'warn') - .mockImplementation(() => {}); - - // Run the function outside of any context - await FixLLMEditWithInstruction( - instruction, - old_string, - new_string, - error, - current_content, - mockBaseLlmClient, - abortSignal, - ); - - // Verify the warning was logged - expect(consoleWarnSpy).toHaveBeenCalledWith( - expect.stringContaining( - 'Could not find promptId in context. This is unexpected. Using a fallback ID: llm-fixer-fallback-', - ), - ); - - // Verify that generateJson was called with the generated fallback promptId - expect(mockGenerateJson).toHaveBeenCalledTimes(1); - expect(mockGenerateJson).toHaveBeenCalledWith( - expect.objectContaining({ - promptId: expect.stringContaining('llm-fixer-fallback-'), - }), - ); - - // Restore mocks - consoleWarnSpy.mockRestore(); - }); - - it('should construct the user prompt correctly', async () => { - mockGenerateJson.mockResolvedValue(mockApiResponse); - const promptId = 'test-prompt-id-prompt-construction'; - - await promptIdContext.run(promptId, async () => { - await FixLLMEditWithInstruction( - instruction, - old_string, - new_string, - error, - current_content, - mockBaseLlmClient, - abortSignal, - ); - }); - - const generateJsonCall = mockGenerateJson.mock.calls[0][0]; - const userPromptContent = generateJsonCall.contents[0].parts[0].text; - - expect(userPromptContent).toContain( - `\n${instruction}\n`, - ); - expect(userPromptContent).toContain(`\n${old_string}\n`); - expect(userPromptContent).toContain(`\n${new_string}\n`); - expect(userPromptContent).toContain(`\n${error}\n`); - expect(userPromptContent).toContain( - `\n${current_content}\n`, - ); - }); - - it('should return a cached result on subsequent identical calls', async () => { - mockGenerateJson.mockResolvedValue(mockApiResponse); - const testPromptId = 'test-prompt-id-caching'; - - await promptIdContext.run(testPromptId, async () => { - // First call - should call the API - const result1 = await FixLLMEditWithInstruction( - instruction, - old_string, - new_string, - error, - current_content, - mockBaseLlmClient, - abortSignal, - ); - - // Second call with identical parameters - should hit the cache - const result2 = await FixLLMEditWithInstruction( - instruction, - old_string, - new_string, - error, - current_content, - mockBaseLlmClient, - abortSignal, - ); - - expect(result1).toEqual(mockApiResponse); - expect(result2).toEqual(mockApiResponse); - // Verify the underlying service was only called ONCE - expect(mockGenerateJson).toHaveBeenCalledTimes(1); - }); - }); - - it('should not use cache for calls with different parameters', async () => { - mockGenerateJson.mockResolvedValue(mockApiResponse); - const testPromptId = 'test-prompt-id-cache-miss'; - - await promptIdContext.run(testPromptId, async () => { - // First call - await FixLLMEditWithInstruction( - instruction, - old_string, - new_string, - error, - current_content, - mockBaseLlmClient, - abortSignal, - ); - - // Second call with a different instruction - await FixLLMEditWithInstruction( - 'A different instruction', - old_string, - new_string, - error, - current_content, - mockBaseLlmClient, - abortSignal, - ); - - // Verify the underlying service was called TWICE - expect(mockGenerateJson).toHaveBeenCalledTimes(2); - }); - }); - - describe('cache collision prevention', () => { - it('should prevent cache collisions when parameters contain separator sequences', async () => { - // This test would have failed with the old string concatenation approach - // but passes with JSON.stringify implementation - - const firstResponse: SearchReplaceEdit = { - search: 'original text', - replace: 'first replacement', - noChangesRequired: false, - explanation: 'First edit correction', - }; - - const secondResponse: SearchReplaceEdit = { - search: 'different text', - replace: 'second replacement', - noChangesRequired: false, - explanation: 'Second edit correction', - }; - - mockGenerateJson - .mockResolvedValueOnce(firstResponse) - .mockResolvedValueOnce(secondResponse); - - const testPromptId = 'cache-collision-test'; - - await promptIdContext.run(testPromptId, async () => { - // Scenario 1: Parameters that would create collision with string concatenation - // Cache key with old method would be: "Fix YAML---content---update--some---data--error" - const call1 = await FixLLMEditWithInstruction( - 'Fix YAML', // instruction - 'content', // old_string - 'update--some', // new_string (contains --) - 'data', // current_content - 'error', // error - mockBaseLlmClient, - abortSignal, - ); - - // Scenario 2: Different parameters that would create same cache key with concatenation - // Cache key with old method would be: "Fix YAML---content---update--some---data--error" - const call2 = await FixLLMEditWithInstruction( - 'Fix YAML---content---update', // instruction (contains ---) - 'some---data', // old_string (contains ---) - 'error', // new_string - '', // current_content - '', // error - mockBaseLlmClient, - abortSignal, - ); - - // With the fixed JSON.stringify approach, these should be different - // and each should get its own LLM response - expect(call1).toEqual(firstResponse); - expect(call2).toEqual(secondResponse); - expect(call1).not.toEqual(call2); - - // Most importantly: the LLM should be called TWICE, not once - // (proving no cache collision occurred) - expect(mockGenerateJson).toHaveBeenCalledTimes(2); - }); - }); - - it('should handle YAML frontmatter without cache collisions', async () => { - // Real-world test case with YAML frontmatter containing --- - - const yamlResponse: SearchReplaceEdit = { - search: '---\ntitle: Old\n---', - replace: '---\ntitle: New\n---', - noChangesRequired: false, - explanation: 'Updated YAML frontmatter', - }; - - const contentResponse: SearchReplaceEdit = { - search: 'old content', - replace: 'new content', - noChangesRequired: false, - explanation: 'Updated content', - }; - - mockGenerateJson - .mockResolvedValueOnce(yamlResponse) - .mockResolvedValueOnce(contentResponse); - - const testPromptId = 'yaml-frontmatter-test'; - - await promptIdContext.run(testPromptId, async () => { - // Call 1: Edit YAML frontmatter - const yamlEdit = await FixLLMEditWithInstruction( - 'Update YAML frontmatter', - '---\ntitle: Old\n---', // Contains --- - '---\ntitle: New\n---', // Contains --- - 'Some markdown content', - 'YAML parse error', - mockBaseLlmClient, - abortSignal, - ); - - // Call 2: Edit regular content - const contentEdit = await FixLLMEditWithInstruction( - 'Update content', - 'old content', - 'new content', - 'Different file content', - 'Content not found', - mockBaseLlmClient, - abortSignal, - ); - - // Verify both calls succeeded with different results - expect(yamlEdit).toEqual(yamlResponse); - expect(contentEdit).toEqual(contentResponse); - expect(yamlEdit).not.toEqual(contentEdit); - - // Verify no cache collision - both calls should hit the LLM - expect(mockGenerateJson).toHaveBeenCalledTimes(2); - }); - }); - }); -}); diff --git a/packages/core/src/utils/llm-edit-fixer.ts b/packages/core/src/utils/llm-edit-fixer.ts deleted file mode 100644 index bc81c8e62..000000000 --- a/packages/core/src/utils/llm-edit-fixer.ts +++ /dev/null @@ -1,164 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { createHash } from 'node:crypto'; -import { type Content, Type } from '@google/genai'; -import { type BaseLlmClient } from '../core/baseLlmClient.js'; -import { LruCache } from './LruCache.js'; -import { DEFAULT_QWEN_FLASH_MODEL } from '../config/models.js'; -import { promptIdContext } from './promptIdContext.js'; - -const MAX_CACHE_SIZE = 50; - -const EDIT_SYS_PROMPT = ` -You are an expert code-editing assistant specializing in debugging and correcting failed search-and-replace operations. - -# Primary Goal -Your task is to analyze a failed edit attempt and provide a corrected \`search\` string that will match the text in the file precisely. The correction should be as minimal as possible, staying very close to the original, failed \`search\` string. Do NOT invent a completely new edit based on the instruction; your job is to fix the provided parameters. - -It is important that you do no try to figure out if the instruction is correct. DO NOT GIVE ADVICE. Your only goal here is to do your best to perform the search and replace task! - -# Input Context -You will be given: -1. The high-level instruction for the original edit. -2. The exact \`search\` and \`replace\` strings that failed. -3. The error message that was produced. -4. The full content of the source file. - -# Rules for Correction -1. **Minimal Correction:** Your new \`search\` string must be a close variation of the original. Focus on fixing issues like whitespace, indentation, line endings, or small contextual differences. -2. **Explain the Fix:** Your \`explanation\` MUST state exactly why the original \`search\` failed and how your new \`search\` string resolves that specific failure. (e.g., "The original search failed due to incorrect indentation; the new search corrects the indentation to match the source file."). -3. **Preserve the \`replace\` String:** Do NOT modify the \`replace\` string unless the instruction explicitly requires it and it was the source of the error. Your primary focus is fixing the \`search\` string. -4. **No Changes Case:** CRUCIAL: if the change is already present in the file, set \`noChangesRequired\` to True and explain why in the \`explanation\`. It is crucial that you only do this if the changes outline in \`replace\` are alredy in the file and suits the instruction!! -5. **Exactness:** The final \`search\` field must be the EXACT literal text from the file. Do not escape characters. -`; - -const EDIT_USER_PROMPT = ` -# Goal of the Original Edit - -{instruction} - - -# Failed Attempt Details -- **Original \`search\` parameter (failed):** - -{old_string} - -- **Original \`replace\` parameter:** - -{new_string} - -- **Error Encountered:** - -{error} - - -# Full File Content - -{current_content} - - -# Your Task -Based on the error and the file content, provide a corrected \`search\` string that will succeed. Remember to keep your correction minimal and explain the precise reason for the failure in your \`explanation\`. -`; - -export interface SearchReplaceEdit { - search: string; - replace: string; - noChangesRequired: boolean; - explanation: string; -} - -const SearchReplaceEditSchema = { - type: Type.OBJECT, - properties: { - explanation: { type: Type.STRING }, - search: { type: Type.STRING }, - replace: { type: Type.STRING }, - noChangesRequired: { type: Type.BOOLEAN }, - }, - required: ['search', 'replace', 'explanation'], -}; - -const editCorrectionWithInstructionCache = new LruCache< - string, - SearchReplaceEdit ->(MAX_CACHE_SIZE); - -/** - * Attempts to fix a failed edit by using an LLM to generate a new search and replace pair. - * @param instruction The instruction for what needs to be done. - * @param old_string The original string to be replaced. - * @param new_string The original replacement string. - * @param error The error that occurred during the initial edit. - * @param current_content The current content of the file. - * @param baseLlmClient The BaseLlmClient to use for the LLM call. - * @param abortSignal An abort signal to cancel the operation. - * @param promptId A unique ID for the prompt. - * @returns A new search and replace pair. - */ -export async function FixLLMEditWithInstruction( - instruction: string, - old_string: string, - new_string: string, - error: string, - current_content: string, - baseLlmClient: BaseLlmClient, - abortSignal: AbortSignal, -): Promise { - let promptId = promptIdContext.getStore(); - if (!promptId) { - promptId = `llm-fixer-fallback-${Date.now()}-${Math.random().toString(16).slice(2)}`; - console.warn( - `Could not find promptId in context. This is unexpected. Using a fallback ID: ${promptId}`, - ); - } - - const cacheKey = createHash('sha256') - .update( - JSON.stringify([ - current_content, - old_string, - new_string, - instruction, - error, - ]), - ) - .digest('hex'); - const cachedResult = editCorrectionWithInstructionCache.get(cacheKey); - if (cachedResult) { - return cachedResult; - } - const userPrompt = EDIT_USER_PROMPT.replace('{instruction}', instruction) - .replace('{old_string}', old_string) - .replace('{new_string}', new_string) - .replace('{error}', error) - .replace('{current_content}', current_content); - - const contents: Content[] = [ - { - role: 'user', - parts: [{ text: userPrompt }], - }, - ]; - - const result = (await baseLlmClient.generateJson({ - contents, - schema: SearchReplaceEditSchema, - abortSignal, - model: DEFAULT_QWEN_FLASH_MODEL, - systemInstruction: EDIT_SYS_PROMPT, - promptId, - maxAttempts: 1, - })) as unknown as SearchReplaceEdit; - - editCorrectionWithInstructionCache.set(cacheKey, result); - return result; -} - -export function resetLlmEditFixerCaches_TEST_ONLY() { - editCorrectionWithInstructionCache.clear(); -} From c06a143150d6b512c1acc9eec4630d92dc1f2caa Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Sun, 1 Feb 2026 14:52:14 +0800 Subject: [PATCH 07/49] fix: Remove remaining ClearcutLogger export from packages/core/index.ts Co-authored-by: Qwen-Coder --- packages/core/index.ts | 2 -- packages/core/src/index.ts | 8 +++----- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/packages/core/index.ts b/packages/core/index.ts index aab675a18..c9e5a276c 100644 --- a/packages/core/index.ts +++ b/packages/core/index.ts @@ -42,6 +42,4 @@ export { export { makeFakeConfig } from './src/test-utils/config.js'; export * from './src/utils/pathReader.js'; export * from './src/utils/request-tokenizer/supportedImageFormats.js'; -export { ClearcutLogger } from './src/telemetry/clearcut-logger/clearcut-logger.js'; -export { QwenLogger } from './src/telemetry/qwen-logger/qwen-logger.js'; export { logModelSlashCommand } from './src/telemetry/loggers.js'; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b9016efcc..47a3f789f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -166,12 +166,10 @@ export type { } from './mcp/oauth-utils.js'; export { OAuthUtils } from './mcp/oauth-utils.js'; -// ============================================================================ -// Telemetry -// ============================================================================ - -export { QwenLogger } from './telemetry/qwen-logger/qwen-logger.js'; +// Export telemetry functions export * from './telemetry/index.js'; +export { QwenLogger } from './telemetry/qwen-logger/qwen-logger.js'; + export * from './utils/browser.js'; // OpenAI Logging Utilities export { OpenAILogger, openaiLogger } from './utils/openaiLogger.js'; From 30ac136a9371e32f8d3ef235147dcef30fae444a Mon Sep 17 00:00:00 2001 From: Alexander Farber Date: Wed, 24 Dec 2025 11:16:22 +0100 Subject: [PATCH 08/49] Check disableUpdateNag before making network request --- packages/cli/src/utils/handleAutoUpdate.test.ts | 8 -------- packages/cli/src/utils/handleAutoUpdate.ts | 4 ---- 2 files changed, 12 deletions(-) diff --git a/packages/cli/src/utils/handleAutoUpdate.test.ts b/packages/cli/src/utils/handleAutoUpdate.test.ts index d448b9e7b..8f038d7f8 100644 --- a/packages/cli/src/utils/handleAutoUpdate.test.ts +++ b/packages/cli/src/utils/handleAutoUpdate.test.ts @@ -94,14 +94,6 @@ describe('handleAutoUpdate', () => { expect(mockSpawn).not.toHaveBeenCalled(); }); - it('should do nothing if update nag is disabled', () => { - mockSettings.merged.general!.disableUpdateNag = true; - handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn); - expect(mockGetInstallationInfo).not.toHaveBeenCalled(); - expect(mockUpdateEventEmitter.emit).not.toHaveBeenCalled(); - expect(mockSpawn).not.toHaveBeenCalled(); - }); - it('should emit "update-received" but not update if auto-updates are disabled', () => { mockSettings.merged.general!.disableAutoUpdate = true; mockGetInstallationInfo.mockReturnValue({ diff --git a/packages/cli/src/utils/handleAutoUpdate.ts b/packages/cli/src/utils/handleAutoUpdate.ts index 21ff7be63..676aa981a 100644 --- a/packages/cli/src/utils/handleAutoUpdate.ts +++ b/packages/cli/src/utils/handleAutoUpdate.ts @@ -24,10 +24,6 @@ export function handleAutoUpdate( return; } - if (settings.merged.general?.disableUpdateNag) { - return; - } - const installationInfo = getInstallationInfo( projectRoot, settings.merged.general?.disableAutoUpdate ?? false, From c90dbcdf3bf054b8504b0064eb438cc9e90a6fbf Mon Sep 17 00:00:00 2001 From: Alexander Farber Date: Wed, 24 Dec 2025 11:26:08 +0100 Subject: [PATCH 09/49] Add a test --- packages/cli/src/gemini.test.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 25db908c4..7a1647278 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -643,7 +643,7 @@ describe('startInteractiveUI', () => { expect(checkForUpdates).toHaveBeenCalledTimes(1); }); - it('should not check for updates when update nag is disabled', async () => { + it('should not check for updates when enableAutoUpdate is false', async () => { const { checkForUpdates } = await import('./ui/utils/updateCheck.js'); const mockInitializationResult = { @@ -653,10 +653,10 @@ describe('startInteractiveUI', () => { geminiMdFileCount: 0, }; - const settingsWithUpdateNagDisabled = { + const settingsWithAutoUpdateDisabled = { merged: { general: { - disableUpdateNag: true, + enableAutoUpdate: false, }, ui: { hideWindowTitle: false, @@ -666,7 +666,7 @@ describe('startInteractiveUI', () => { await startInteractiveUI( mockConfig, - settingsWithUpdateNagDisabled, + settingsWithAutoUpdateDisabled, mockStartupWarnings, mockWorkspaceRoot, mockInitializationResult, From 561fda297eb69c25135324331abe39a35ef6cca8 Mon Sep 17 00:00:00 2001 From: Alexander Farber Date: Mon, 29 Dec 2025 23:18:54 +0100 Subject: [PATCH 10/49] Rename disable* settings to enable* and consolidated 1 setting --- docs/users/configuration/settings.md | 38 +++--- packages/cli/src/config/settings.ts | 116 +++++++++++++++++- .../cli/src/config/settingsSchema.test.ts | 4 +- packages/cli/src/config/settingsSchema.ts | 46 +++---- packages/cli/src/gemini.test.tsx | 18 +-- packages/cli/src/gemini.tsx | 4 +- packages/cli/src/ui/components/Composer.tsx | 6 +- packages/cli/src/ui/hooks/useAtCompletion.ts | 5 +- .../cli/src/utils/handleAutoUpdate.test.ts | 17 ++- packages/cli/src/utils/handleAutoUpdate.ts | 13 +- packages/cli/src/utils/installationInfo.ts | 26 ++-- packages/cli/src/utils/settingsUtils.test.ts | 62 +++++----- packages/core/src/config/config.ts | 12 +- packages/core/src/core/contentGenerator.ts | 2 +- .../provider/dashscope.test.ts | 4 +- .../provider/dashscope.ts | 11 +- .../src/utils/filesearch/fileSearch.test.ts | 56 ++++----- .../core/src/utils/filesearch/fileSearch.ts | 9 +- 18 files changed, 285 insertions(+), 164 deletions(-) diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index 2ce94c38b..dfd70cdcd 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -55,8 +55,7 @@ Settings are organized into categories. All settings should be placed within the | ------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------- | ----------- | | `general.preferredEditor` | string | The preferred editor to open files in. | `undefined` | | `general.vimMode` | boolean | Enable Vim keybindings. | `false` | -| `general.disableAutoUpdate` | boolean | Disable automatic updates. | `false` | -| `general.disableUpdateNag` | boolean | Disable update notification prompts. | `false` | +| `general.enableAutoUpdate` | boolean | Enable automatic update checks and installations on startup. | `true` | | `general.gitCoAuthor` | boolean | Automatically add a Co-authored-by trailer to git commit messages when commits are made through Qwen Code. | `true` | | `general.checkpointing.enabled` | boolean | Enable session checkpointing for recovery. | `false` | @@ -68,18 +67,21 @@ Settings are organized into categories. All settings should be placed within the #### ui -| Setting | Type | Description | Default | -| ---------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | -| `ui.theme` | string | The color theme for the UI. See [Themes](../configuration/themes) for available options. | `undefined` | -| `ui.customThemes` | object | Custom theme definitions. | `{}` | -| `ui.hideWindowTitle` | boolean | Hide the window title bar. | `false` | -| `ui.hideTips` | boolean | Hide helpful tips in the UI. | `false` | -| `ui.showLineNumbers` | boolean | Show line numbers in code blocks in the CLI output. | `true` | -| `ui.showCitations` | boolean | Show citations for generated text in the chat. | `true` | -| `enableWelcomeBack` | boolean | Show welcome back dialog when returning to a project with conversation history. When enabled, Qwen Code will automatically detect if you're returning to a project with a previously generated project summary (`.qwen/PROJECT_SUMMARY.md`) and show a dialog allowing you to continue your previous conversation or start fresh. This feature integrates with the `/summary` command and quit confirmation dialog. | `true` | -| `ui.accessibility.disableLoadingPhrases` | boolean | Disable loading phrases for accessibility. | `false` | -| `ui.accessibility.screenReader` | boolean | Enables screen reader mode, which adjusts the TUI for better compatibility with screen readers. | `false` | -| `ui.customWittyPhrases` | array of strings | A list of custom phrases to display during loading states. When provided, the CLI will cycle through these phrases instead of the default ones. | `[]` | +| Setting | Type | Description | Default | +| --------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | +| `ui.theme` | string | The color theme for the UI. See [Themes](../configuration/themes) for available options. | `undefined` | +| `ui.customThemes` | object | Custom theme definitions. | `{}` | +| `ui.hideWindowTitle` | boolean | Hide the window title bar. | `false` | +| `ui.hideTips` | boolean | Hide helpful tips in the UI. | `false` | +| `ui.hideBanner` | boolean | Hide the application banner. | `false` | +| `ui.hideFooter` | boolean | Hide the footer from the UI. | `false` | +| `ui.showMemoryUsage` | boolean | Display memory usage information in the UI. | `false` | +| `ui.showLineNumbers` | boolean | Show line numbers in code blocks in the CLI output. | `true` | +| `ui.showCitations` | boolean | Show citations for generated text in the chat. | `true` | +| `enableWelcomeBack` | boolean | Show welcome back dialog when returning to a project with conversation history. When enabled, Qwen Code will automatically detect if you're returning to a project with a previously generated project summary (`.qwen/PROJECT_SUMMARY.md`) and show a dialog allowing you to continue your previous conversation or start fresh. This feature integrates with the `/summary` command and quit confirmation dialog. | `true` | +| `ui.accessibility.enableLoadingPhrases` | boolean | Enable loading phrases (disable for accessibility). | `true` | +| `ui.accessibility.screenReader` | boolean | Enables screen reader mode, which adjusts the TUI for better compatibility with screen readers. | `false` | +| `ui.customWittyPhrases` | array of strings | A list of custom phrases to display during loading states. When provided, the CLI will cycle through these phrases instead of the default ones. | `[]` | #### ide @@ -101,7 +103,7 @@ Settings are organized into categories. All settings should be placed within the | `model.name` | string | The Qwen model to use for conversations. | `undefined` | | `model.maxSessionTurns` | number | Maximum number of user/model/tool turns to keep in a session. -1 means unlimited. | `-1` | | `model.summarizeToolOutput` | object | Enables or disables the summarization of tool output. You can specify the token budget for the summarization using the `tokenBudget` setting. Note: Currently only the `run_shell_command` tool is supported. For example `{"run_shell_command": {"tokenBudget": 2000}}` | `undefined` | -| `model.generationConfig` | object | Advanced overrides passed to the underlying content generator. Supports request controls such as `timeout`, `maxRetries`, `disableCacheControl`, `contextWindowSize` (override model's context window size), `customHeaders` (custom HTTP headers for API requests), and `extra_body` (additional body parameters for OpenAI-compatible API requests only), along with fine-tuning knobs under `samplingParams` (for example `temperature`, `top_p`, `max_tokens`). Leave unset to rely on provider defaults. | `undefined` | +| `model.generationConfig` | object | Advanced overrides passed to the underlying content generator. Supports request controls such as `timeout`, `maxRetries`, `enableCacheControl`, `contextWindowSize` (override model's context window size), `customHeaders` (custom HTTP headers for API requests), and `extra_body` (additional body parameters for OpenAI-compatible API requests only), along with fine-tuning knobs under `samplingParams` (for example `temperature`, `top_p`, `max_tokens`). Leave unset to rely on provider defaults. | `undefined` | | `model.chatCompression.contextPercentageThreshold` | number | Sets the threshold for chat history compression as a percentage of the model's total token limit. This is a value between 0 and 1 that applies to both automatic compression and the manual `/compress` command. For example, a value of `0.6` will trigger compression when the chat history exceeds 60% of the token limit. Use `0` to disable compression entirely. | `0.7` | | `model.skipNextSpeakerCheck` | boolean | Skip the next speaker check. | `false` | | `model.skipLoopDetection` | boolean | Disables loop detection checks. Loop detection prevents infinite loops in AI responses but can generate false positives that interrupt legitimate workflows. Enable this option if you experience frequent false positive loop detection interruptions. | `false` | @@ -116,8 +118,8 @@ Settings are organized into categories. All settings should be placed within the "model": { "generationConfig": { "timeout": 60000, - "disableCacheControl": false, "contextWindowSize": 128000, + "enableCacheControl": true, "customHeaders": { "X-Request-ID": "req-123", "X-User-ID": "user-456" @@ -258,14 +260,14 @@ Per-field precedence for `generationConfig`: | `context.fileFiltering.respectGitIgnore` | boolean | Respect .gitignore files when searching. | `true` | | `context.fileFiltering.respectQwenIgnore` | boolean | Respect .qwenignore files when searching. | `true` | | `context.fileFiltering.enableRecursiveFileSearch` | boolean | Whether to enable searching recursively for filenames under the current tree when completing `@` prefixes in the prompt. | `true` | -| `context.fileFiltering.disableFuzzySearch` | boolean | When `true`, disables the fuzzy search capabilities when searching for files, which can improve performance on projects with a large number of files. | `false` | +| `context.fileFiltering.enableFuzzySearch` | boolean | When `true`, enables fuzzy search capabilities when searching for files. Set to `false` to improve performance on projects with a large number of files. | `true` | #### Troubleshooting File Search Performance If you are experiencing performance issues with file searching (e.g., with `@` completions), especially in projects with a very large number of files, here are a few things you can try in order of recommendation: 1. **Use `.qwenignore`:** Create a `.qwenignore` file in your project root to exclude directories that contain a large number of files that you don't need to reference (e.g., build artifacts, logs, `node_modules`). Reducing the total number of files crawled is the most effective way to improve performance. -2. **Disable Fuzzy Search:** If ignoring files is not enough, you can disable fuzzy search by setting `disableFuzzySearch` to `true` in your `settings.json` file. This will use a simpler, non-fuzzy matching algorithm, which can be faster. +2. **Disable Fuzzy Search:** If ignoring files is not enough, you can disable fuzzy search by setting `enableFuzzySearch` to `false` in your `settings.json` file. This will use a simpler, non-fuzzy matching algorithm, which can be faster. 3. **Disable Recursive File Search:** As a last resort, you can disable recursive file search entirely by setting `enableRecursiveFileSearch` to `false`. This will be the fastest option as it avoids a recursive crawl of your project. However, it means you will need to type the full path to files when using `@` completions. #### tools diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 0f213acf3..ea41c578c 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -56,7 +56,7 @@ export const DEFAULT_EXCLUDED_ENV_VARS = ['DEBUG', 'DEBUG_MODE']; const MIGRATE_V2_OVERWRITE = true; // Settings version to track migration state -export const SETTINGS_VERSION = 2; +export const SETTINGS_VERSION = 3; export const SETTINGS_VERSION_KEY = '$version'; const MIGRATION_MAP: Record = { @@ -73,8 +73,6 @@ const MIGRATION_MAP: Record = { customThemes: 'ui.customThemes', customWittyPhrases: 'ui.customWittyPhrases', debugKeystrokeLogging: 'general.debugKeystrokeLogging', - disableAutoUpdate: 'general.disableAutoUpdate', - disableUpdateNag: 'general.disableUpdateNag', dnsResolutionOrder: 'advanced.dnsResolutionOrder', enforcedAuthType: 'security.auth.enforcedType', excludeTools: 'tools.exclude', @@ -127,6 +125,28 @@ const MIGRATION_MAP: Record = { visionModelPreview: 'experimental.visionModelPreview', }; +// Settings that need boolean inversion during migration (V1 -> V3) +// Old negative naming -> new positive naming with inverted value +const INVERTED_BOOLEAN_MIGRATIONS: Record = { + disableAutoUpdate: 'general.enableAutoUpdate', + disableUpdateNag: 'general.enableAutoUpdate', + disableLoadingPhrases: 'ui.accessibility.enableLoadingPhrases', + disableFuzzySearch: 'context.fileFiltering.enableFuzzySearch', + disableCacheControl: 'model.generationConfig.enableCacheControl', +}; + +// V2 nested paths that need inversion when migrating to V3 +const INVERTED_V2_PATHS: Record = { + 'general.disableAutoUpdate': 'general.enableAutoUpdate', + 'general.disableUpdateNag': 'general.enableAutoUpdate', + 'ui.accessibility.disableLoadingPhrases': + 'ui.accessibility.enableLoadingPhrases', + 'context.fileFiltering.disableFuzzySearch': + 'context.fileFiltering.enableFuzzySearch', + 'model.generationConfig.disableCacheControl': + 'model.generationConfig.enableCacheControl', +}; + export function getSystemSettingsPath(): string { if (process.env['QWEN_CODE_SYSTEM_SETTINGS_PATH']) { return process.env['QWEN_CODE_SYSTEM_SETTINGS_PATH']; @@ -168,7 +188,7 @@ export interface SummarizeToolOutputSettings { } export interface AccessibilitySettings { - disableLoadingPhrases?: boolean; + enableLoadingPhrases?: boolean; screenReader?: boolean; } @@ -272,6 +292,17 @@ function migrateSettingsToV2( } } + // Handle V1 settings that need boolean inversion (disable* -> enable*) + for (const [oldKey, newPath] of Object.entries(INVERTED_BOOLEAN_MIGRATIONS)) { + if (flatKeys.has(oldKey)) { + const oldValue = flatSettings[oldKey]; + if (typeof oldValue === 'boolean') { + setNestedProperty(v2Settings, newPath, !oldValue); + } + flatKeys.delete(oldKey); + } + } + // Preserve mcpServers at the top level if (flatSettings['mcpServers']) { v2Settings['mcpServers'] = flatSettings['mcpServers']; @@ -310,6 +341,56 @@ function migrateSettingsToV2( return v2Settings; } +// Migrate V2 settings to V3 (invert disable* -> enable* booleans) +function migrateV2ToV3( + settings: Record, +): Record | null { + const version = settings[SETTINGS_VERSION_KEY]; + if (typeof version === 'number' && version >= 3) { + return null; + } + + let changed = false; + const result = structuredClone(settings); + + for (const [oldPath, newPath] of Object.entries(INVERTED_V2_PATHS)) { + const oldValue = getNestedProperty(result, oldPath); + if (typeof oldValue === 'boolean') { + // Remove old property + deleteNestedProperty(result, oldPath); + // Set new property with inverted value + setNestedProperty(result, newPath, !oldValue); + changed = true; + } + } + + if (changed) { + result[SETTINGS_VERSION_KEY] = SETTINGS_VERSION; + return result; + } + + return null; +} + +function deleteNestedProperty( + obj: Record, + path: string, +): void { + const keys = path.split('.'); + const lastKey = keys.pop(); + if (!lastKey) return; + + let current: Record = obj; + for (const key of keys) { + const next = current[key]; + if (typeof next !== 'object' || next === null) { + return; + } + current = next as Record; + } + delete current[lastKey]; +} + function getNestedProperty( obj: Record, path: string, @@ -775,6 +856,33 @@ export function loadSettings( } } } + + // V2 to V3 migration (invert disable* -> enable* booleans) + const v3Migrated = migrateV2ToV3(settingsObject); + if (v3Migrated) { + if (MIGRATE_V2_OVERWRITE) { + try { + // Only backup if not already backed up by V1->V2 migration + const backupPath = `${filePath}.orig`; + if (!fs.existsSync(backupPath)) { + fs.renameSync(filePath, backupPath); + } + fs.writeFileSync( + filePath, + JSON.stringify(v3Migrated, null, 2), + 'utf-8', + ); + } catch (e) { + console.error( + `Error migrating settings file to V3: ${getErrorMessage(e)}`, + ); + } + } else { + migratedInMemorScopes.add(scope); + } + settingsObject = v3Migrated; + } + return { settings: settingsObject as Settings, rawJson: content }; } } catch (error: unknown) { diff --git a/packages/cli/src/config/settingsSchema.test.ts b/packages/cli/src/config/settingsSchema.test.ts index 7d97d5465..fc902234f 100644 --- a/packages/cli/src/config/settingsSchema.test.ts +++ b/packages/cli/src/config/settingsSchema.test.ts @@ -80,7 +80,7 @@ describe('SettingsSchema', () => { ).toBeDefined(); expect( getSettingsSchema().ui?.properties?.accessibility.properties - ?.disableLoadingPhrases.type, + ?.enableLoadingPhrases.type, ).toBe('boolean'); }); @@ -164,7 +164,7 @@ describe('SettingsSchema', () => { true, ); expect( - getSettingsSchema().general.properties.disableAutoUpdate.showInDialog, + getSettingsSchema().general.properties.enableAutoUpdate.showInDialog, ).toBe(true); expect( getSettingsSchema().ui.properties.hideWindowTitle.showInDialog, diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 44340b81e..b1a7f1299 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -143,24 +143,16 @@ const SETTINGS_SCHEMA = { description: 'Enable Vim keybindings', showInDialog: true, }, - disableAutoUpdate: { + enableAutoUpdate: { type: 'boolean', - label: 'Disable Auto Update', + label: 'Enable Auto Update', category: 'General', requiresRestart: false, - default: false, - description: 'Disable automatic updates', + default: true, + description: + 'Enable automatic update checks and installations on startup.', showInDialog: true, }, - disableUpdateNag: { - type: 'boolean', - label: 'Disable Update Nag', - category: 'General', - requiresRestart: false, - default: false, - description: 'Disable update notification prompts.', - showInDialog: false, - }, gitCoAuthor: { type: 'boolean', label: 'Attribution: commit', @@ -382,14 +374,14 @@ const SETTINGS_SCHEMA = { description: 'Accessibility settings.', showInDialog: false, properties: { - disableLoadingPhrases: { + enableLoadingPhrases: { type: 'boolean', - label: 'Disable Loading Phrases', + label: 'Enable Loading Phrases', category: 'UI', requiresRestart: true, - default: false, - description: 'Disable loading phrases for accessibility', - showInDialog: false, + default: true, + description: 'Enable loading phrases (disable for accessibility)', + showInDialog: true, }, screenReader: { type: 'boolean', @@ -609,13 +601,13 @@ const SETTINGS_SCHEMA = { parentKey: 'generationConfig', showInDialog: false, }, - disableCacheControl: { + enableCacheControl: { type: 'boolean', - label: 'Disable Cache Control', + label: 'Enable Cache Control', category: 'Generation Configuration', requiresRestart: false, - default: false, - description: 'Disable cache control for DashScope providers.', + default: true, + description: 'Enable cache control for DashScope providers.', parentKey: 'generationConfig', showInDialog: false, }, @@ -733,14 +725,14 @@ const SETTINGS_SCHEMA = { description: 'Enable recursive file search functionality', showInDialog: false, }, - disableFuzzySearch: { + enableFuzzySearch: { type: 'boolean', - label: 'Disable Fuzzy Search', + label: 'Enable Fuzzy Search', category: 'Context', requiresRestart: true, - default: false, - description: 'Disable fuzzy search when searching for files.', - showInDialog: false, + default: true, + description: 'Enable fuzzy search when searching for files.', + showInDialog: true, }, }, }, diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 7a1647278..981454934 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -643,16 +643,9 @@ describe('startInteractiveUI', () => { expect(checkForUpdates).toHaveBeenCalledTimes(1); }); - it('should not check for updates when enableAutoUpdate is false', async () => { + it('should not call checkForUpdates when enableAutoUpdate is false', async () => { const { checkForUpdates } = await import('./ui/utils/updateCheck.js'); - const mockInitializationResult = { - authError: null, - themeError: null, - shouldOpenAuthDialog: false, - geminiMdFileCount: 0, - }; - const settingsWithAutoUpdateDisabled = { merged: { general: { @@ -664,6 +657,13 @@ describe('startInteractiveUI', () => { }, } as LoadedSettings; + const mockInitializationResult = { + authError: null, + themeError: null, + shouldOpenAuthDialog: false, + geminiMdFileCount: 0, + }; + await startInteractiveUI( mockConfig, settingsWithAutoUpdateDisabled, @@ -673,6 +673,8 @@ describe('startInteractiveUI', () => { ); await new Promise((resolve) => setTimeout(resolve, 0)); + + // checkForUpdates should NOT be called when enableAutoUpdate is false expect(checkForUpdates).not.toHaveBeenCalled(); }); }); diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 16fea6311..da7f29151 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -182,7 +182,9 @@ export async function startInteractiveUI( }, ); - if (!settings.merged.general?.disableUpdateNag) { + // Check for updates only if enableAutoUpdate is not explicitly disabled. + // Using !== false ensures updates are enabled by default when undefined. + if (settings.merged.general?.enableAutoUpdate !== false) { checkForUpdates() .then((info) => { handleAutoUpdate(info, settings, config.getProjectRoot()); diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index 50b04a1d2..30608a961 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -56,14 +56,16 @@ export const Composer = () => { {!uiState.embeddedShellFocused && ( { mockSettings = { merged: { general: { - disableAutoUpdate: false, + enableAutoUpdate: true, }, }, } as LoadedSettings; @@ -94,24 +94,29 @@ describe('handleAutoUpdate', () => { expect(mockSpawn).not.toHaveBeenCalled(); }); - it('should emit "update-received" but not update if auto-updates are disabled', () => { - mockSettings.merged.general!.disableAutoUpdate = true; + it('should show manual update message when enableAutoUpdate is false', () => { + // When enableAutoUpdate is false, gemini.tsx won't call checkForUpdates(), + // but if handleAutoUpdate is still called, it should show a manual update message. + mockSettings.merged.general!.enableAutoUpdate = false; mockGetInstallationInfo.mockReturnValue({ updateCommand: 'npm i -g @qwen-code/qwen-code@latest', - updateMessage: 'Please update manually.', + updateMessage: + 'Please run npm i -g @qwen-code/qwen-code@latest to update', isGlobal: true, packageManager: PackageManager.NPM, }); handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn); - expect(mockUpdateEventEmitter.emit).toHaveBeenCalledTimes(1); + // Should still emit update-received with manual update message expect(mockUpdateEventEmitter.emit).toHaveBeenCalledWith( 'update-received', { - message: 'An update is available!\nPlease update manually.', + message: + 'An update is available!\nPlease run npm i -g @qwen-code/qwen-code@latest to update', }, ); + // Should NOT spawn update when enableAutoUpdate is false expect(mockSpawn).not.toHaveBeenCalled(); }); diff --git a/packages/cli/src/utils/handleAutoUpdate.ts b/packages/cli/src/utils/handleAutoUpdate.ts index 676aa981a..e02c0f615 100644 --- a/packages/cli/src/utils/handleAutoUpdate.ts +++ b/packages/cli/src/utils/handleAutoUpdate.ts @@ -24,9 +24,14 @@ export function handleAutoUpdate( return; } + // enableAutoUpdate is checked in gemini.tsx before calling this function, + // so if we get here, auto-update is enabled (or undefined, which defaults to enabled). + const isAutoUpdateEnabled = + settings.merged.general?.enableAutoUpdate !== false; + const installationInfo = getInstallationInfo( projectRoot, - settings.merged.general?.disableAutoUpdate ?? false, + isAutoUpdateEnabled, ); let combinedMessage = info.message; @@ -38,10 +43,8 @@ export function handleAutoUpdate( message: combinedMessage, }); - if ( - !installationInfo.updateCommand || - settings.merged.general?.disableAutoUpdate - ) { + // Don't automatically run the update if auto-update is disabled or no update command + if (!installationInfo.updateCommand || !isAutoUpdateEnabled) { return; } const isNightly = info.update.latest.includes('nightly'); diff --git a/packages/cli/src/utils/installationInfo.ts b/packages/cli/src/utils/installationInfo.ts index 0805ad218..d910c158d 100644 --- a/packages/cli/src/utils/installationInfo.ts +++ b/packages/cli/src/utils/installationInfo.ts @@ -30,7 +30,7 @@ export interface InstallationInfo { export function getInstallationInfo( projectRoot: string, - isAutoUpdateDisabled: boolean, + isAutoUpdateEnabled: boolean, ): InstallationInfo { const cliPath = process.argv[1]; if (!cliPath) { @@ -99,9 +99,9 @@ export function getInstallationInfo( packageManager: PackageManager.PNPM, isGlobal: true, updateCommand, - updateMessage: isAutoUpdateDisabled - ? `Please run ${updateCommand} to update` - : 'Installed with pnpm. Attempting to automatically update now...', + updateMessage: isAutoUpdateEnabled + ? 'Installed with pnpm. Attempting to automatically update now...' + : `Please run ${updateCommand} to update`, }; } @@ -112,9 +112,9 @@ export function getInstallationInfo( packageManager: PackageManager.YARN, isGlobal: true, updateCommand, - updateMessage: isAutoUpdateDisabled - ? `Please run ${updateCommand} to update` - : 'Installed with yarn. Attempting to automatically update now...', + updateMessage: isAutoUpdateEnabled + ? 'Installed with yarn. Attempting to automatically update now...' + : `Please run ${updateCommand} to update`, }; } @@ -132,9 +132,9 @@ export function getInstallationInfo( packageManager: PackageManager.BUN, isGlobal: true, updateCommand, - updateMessage: isAutoUpdateDisabled - ? `Please run ${updateCommand} to update` - : 'Installed with bun. Attempting to automatically update now...', + updateMessage: isAutoUpdateEnabled + ? 'Installed with bun. Attempting to automatically update now...' + : `Please run ${updateCommand} to update`, }; } @@ -165,9 +165,9 @@ export function getInstallationInfo( packageManager: PackageManager.NPM, isGlobal: true, updateCommand, - updateMessage: isAutoUpdateDisabled - ? `Please run ${updateCommand} to update` - : 'Installed with npm. Attempting to automatically update now...', + updateMessage: isAutoUpdateEnabled + ? 'Installed with npm. Attempting to automatically update now...' + : `Please run ${updateCommand} to update`, }; } catch (error) { console.log(error); diff --git a/packages/cli/src/utils/settingsUtils.test.ts b/packages/cli/src/utils/settingsUtils.test.ts index 109197be3..223e83f0b 100644 --- a/packages/cli/src/utils/settingsUtils.test.ts +++ b/packages/cli/src/utils/settingsUtils.test.ts @@ -121,7 +121,7 @@ describe('SettingsUtils', () => { description: 'Accessibility settings.', showInDialog: false, properties: { - disableLoadingPhrases: { + enableLoadingPhrases: { type: 'boolean', label: 'Disable Loading Phrases', category: 'UI', @@ -285,14 +285,14 @@ describe('SettingsUtils', () => { it('should handle nested settings correctly', () => { const settings = makeMockSettings({ - ui: { accessibility: { disableLoadingPhrases: true } }, + ui: { accessibility: { enableLoadingPhrases: true } }, }); const mergedSettings = makeMockSettings({ - ui: { accessibility: { disableLoadingPhrases: false } }, + ui: { accessibility: { enableLoadingPhrases: false } }, }); const value = getEffectiveValue( - 'ui.accessibility.disableLoadingPhrases', + 'ui.accessibility.enableLoadingPhrases', settings, mergedSettings, ); @@ -316,7 +316,7 @@ describe('SettingsUtils', () => { it('should return all setting keys', () => { const keys = getAllSettingKeys(); expect(keys).toContain('test'); - expect(keys).toContain('ui.accessibility.disableLoadingPhrases'); + expect(keys).toContain('ui.accessibility.enableLoadingPhrases'); }); }); @@ -343,9 +343,9 @@ describe('SettingsUtils', () => { describe('isValidSettingKey', () => { it('should return true for valid setting keys', () => { expect(isValidSettingKey('ui.requiresRestart')).toBe(true); - expect( - isValidSettingKey('ui.accessibility.disableLoadingPhrases'), - ).toBe(true); + expect(isValidSettingKey('ui.accessibility.enableLoadingPhrases')).toBe( + true, + ); }); it('should return false for invalid setting keys', () => { @@ -358,7 +358,7 @@ describe('SettingsUtils', () => { it('should return correct category for valid settings', () => { expect(getSettingCategory('ui.requiresRestart')).toBe('UI'); expect( - getSettingCategory('ui.accessibility.disableLoadingPhrases'), + getSettingCategory('ui.accessibility.enableLoadingPhrases'), ).toBe('UI'); }); @@ -392,7 +392,7 @@ describe('SettingsUtils', () => { const uiSettings = categories['UI']; const uiKeys = uiSettings.map((s) => s.key); expect(uiKeys).toContain('ui.requiresRestart'); - expect(uiKeys).toContain('ui.accessibility.disableLoadingPhrases'); + expect(uiKeys).toContain('ui.accessibility.enableLoadingPhrases'); expect(uiKeys).not.toContain('ui.theme'); // This is now marked false }); @@ -422,7 +422,7 @@ describe('SettingsUtils', () => { const keys = booleanSettings.map((s) => s.key); expect(keys).toContain('ui.requiresRestart'); - expect(keys).toContain('ui.accessibility.disableLoadingPhrases'); + expect(keys).toContain('ui.accessibility.enableLoadingPhrases'); expect(keys).not.toContain('privacy.usageStatisticsEnabled'); expect(keys).not.toContain('security.auth.selectedType'); // Advanced setting expect(keys).not.toContain('security.auth.useExternal'); // Advanced setting @@ -455,7 +455,7 @@ describe('SettingsUtils', () => { expect(dialogKeys).toContain('ui.requiresRestart'); // Should include nested settings marked for dialog - expect(dialogKeys).toContain('ui.accessibility.disableLoadingPhrases'); + expect(dialogKeys).toContain('ui.accessibility.enableLoadingPhrases'); // Should NOT include settings marked as hidden expect(dialogKeys).not.toContain('ui.theme'); // Hidden @@ -602,14 +602,14 @@ describe('SettingsUtils', () => { it('should return true when value differs from default', () => { expect(isSettingModified('ui.requiresRestart', true)).toBe(true); expect( - isSettingModified('ui.accessibility.disableLoadingPhrases', true), + isSettingModified('ui.accessibility.enableLoadingPhrases', true), ).toBe(true); }); it('should return false when value matches default', () => { expect(isSettingModified('ui.requiresRestart', false)).toBe(false); expect( - isSettingModified('ui.accessibility.disableLoadingPhrases', false), + isSettingModified('ui.accessibility.enableLoadingPhrases', false), ).toBe(false); }); }); @@ -629,11 +629,11 @@ describe('SettingsUtils', () => { it('should return true for nested settings that exist', () => { const settings = makeMockSettings({ - ui: { accessibility: { disableLoadingPhrases: true } }, + ui: { accessibility: { enableLoadingPhrases: true } }, }); expect( settingExistsInScope( - 'ui.accessibility.disableLoadingPhrases', + 'ui.accessibility.enableLoadingPhrases', settings, ), ).toBe(true); @@ -643,7 +643,7 @@ describe('SettingsUtils', () => { const settings = makeMockSettings({}); expect( settingExistsInScope( - 'ui.accessibility.disableLoadingPhrases', + 'ui.accessibility.enableLoadingPhrases', settings, ), ).toBe(false); @@ -653,7 +653,7 @@ describe('SettingsUtils', () => { const settings = makeMockSettings({ ui: { accessibility: {} } }); expect( settingExistsInScope( - 'ui.accessibility.disableLoadingPhrases', + 'ui.accessibility.enableLoadingPhrases', settings, ), ).toBe(false); @@ -675,25 +675,25 @@ describe('SettingsUtils', () => { it('should set nested setting value', () => { const pendingSettings = makeMockSettings({}); const result = setPendingSettingValue( - 'ui.accessibility.disableLoadingPhrases', + 'ui.accessibility.enableLoadingPhrases', true, pendingSettings, ); - expect(result.ui?.accessibility?.disableLoadingPhrases).toBe(true); + expect(result.ui?.accessibility?.enableLoadingPhrases).toBe(true); }); it('should preserve existing nested settings', () => { const pendingSettings = makeMockSettings({ - ui: { accessibility: { disableLoadingPhrases: false } }, + ui: { accessibility: { enableLoadingPhrases: false } }, }); const result = setPendingSettingValue( - 'ui.accessibility.disableLoadingPhrases', + 'ui.accessibility.enableLoadingPhrases', true, pendingSettings, ); - expect(result.ui?.accessibility?.disableLoadingPhrases).toBe(true); + expect(result.ui?.accessibility?.enableLoadingPhrases).toBe(true); }); it('should not mutate original settings', () => { @@ -1030,7 +1030,7 @@ describe('SettingsUtils', () => { const settings = makeMockSettings({}); // nested setting doesn't exist const result = isDefaultValue( - 'ui.accessibility.disableLoadingPhrases', + 'ui.accessibility.enableLoadingPhrases', settings, ); expect(result).toBe(true); @@ -1038,11 +1038,11 @@ describe('SettingsUtils', () => { it('should return false when nested setting exists in scope', () => { const settings = makeMockSettings({ - ui: { accessibility: { disableLoadingPhrases: true } }, + ui: { accessibility: { enableLoadingPhrases: true } }, }); // nested setting exists const result = isDefaultValue( - 'ui.accessibility.disableLoadingPhrases', + 'ui.accessibility.enableLoadingPhrases', settings, ); expect(result).toBe(false); @@ -1080,14 +1080,14 @@ describe('SettingsUtils', () => { it('should return false for nested settings that exist in scope', () => { const settings = makeMockSettings({ - ui: { accessibility: { disableLoadingPhrases: true } }, + ui: { accessibility: { enableLoadingPhrases: true } }, }); const mergedSettings = makeMockSettings({ - ui: { accessibility: { disableLoadingPhrases: true } }, + ui: { accessibility: { enableLoadingPhrases: true } }, }); const result = isValueInherited( - 'ui.accessibility.disableLoadingPhrases', + 'ui.accessibility.enableLoadingPhrases', settings, mergedSettings, ); @@ -1097,11 +1097,11 @@ describe('SettingsUtils', () => { it('should return true for nested settings that do not exist in scope', () => { const settings = makeMockSettings({}); const mergedSettings = makeMockSettings({ - ui: { accessibility: { disableLoadingPhrases: true } }, + ui: { accessibility: { enableLoadingPhrases: true } }, }); const result = isValueInherited( - 'ui.accessibility.disableLoadingPhrases', + 'ui.accessibility.enableLoadingPhrases', settings, mergedSettings, ); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index af2d28555..cb4da3bca 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -168,7 +168,7 @@ export const APPROVAL_MODE_INFO: Record = { }; export interface AccessibilitySettings { - disableLoadingPhrases?: boolean; + enableLoadingPhrases?: boolean; screenReader?: boolean; } @@ -304,7 +304,7 @@ export interface ConfigParameters { respectGitIgnore?: boolean; respectQwenIgnore?: boolean; enableRecursiveFileSearch?: boolean; - disableFuzzySearch?: boolean; + enableFuzzySearch?: boolean; }; checkpointing?: boolean; proxy?: string; @@ -454,7 +454,7 @@ export class Config { respectGitIgnore: boolean; respectQwenIgnore: boolean; enableRecursiveFileSearch: boolean; - disableFuzzySearch: boolean; + enableFuzzySearch: boolean; }; private fileDiscoveryService: FileDiscoveryService | null = null; private gitService: GitService | undefined = undefined; @@ -572,7 +572,7 @@ export class Config { respectQwenIgnore: params.fileFiltering?.respectQwenIgnore ?? true, enableRecursiveFileSearch: params.fileFiltering?.enableRecursiveFileSearch ?? true, - disableFuzzySearch: params.fileFiltering?.disableFuzzySearch ?? false, + enableFuzzySearch: params.fileFiltering?.enableFuzzySearch ?? true, }; this.checkpointing = params.checkpointing ?? false; this.proxy = params.proxy; @@ -1230,8 +1230,8 @@ export class Config { return this.fileFiltering.enableRecursiveFileSearch; } - getFileFilteringDisableFuzzySearch(): boolean { - return this.fileFiltering.disableFuzzySearch; + getFileFilteringEnableFuzzySearch(): boolean { + return this.fileFiltering.enableFuzzySearch; } getFileFilteringRespectGitIgnore(): boolean { diff --git a/packages/core/src/core/contentGenerator.ts b/packages/core/src/core/contentGenerator.ts index 6ac6d9c72..f3af06bda 100644 --- a/packages/core/src/core/contentGenerator.ts +++ b/packages/core/src/core/contentGenerator.ts @@ -71,7 +71,7 @@ export type ContentGeneratorConfig = { openAILoggingDir?: string; timeout?: number; // Timeout configuration in milliseconds maxRetries?: number; // Maximum retries for failed requests - disableCacheControl?: boolean; // Disable cache control for DashScope providers + enableCacheControl?: boolean; // Enable cache control for DashScope providers samplingParams?: { top_p?: number; top_k?: number; diff --git a/packages/core/src/core/openaiContentGenerator/provider/dashscope.test.ts b/packages/core/src/core/openaiContentGenerator/provider/dashscope.test.ts index 9587f3688..a57bbacb7 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/dashscope.test.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/dashscope.test.ts @@ -66,7 +66,7 @@ describe('DashScopeOpenAICompatibleProvider', () => { getCliVersion: vi.fn().mockReturnValue('1.0.0'), getSessionId: vi.fn().mockReturnValue('test-session-id'), getContentGeneratorConfig: vi.fn().mockReturnValue({ - disableCacheControl: false, + enableCacheControl: true, }), getProxy: vi.fn().mockReturnValue(undefined), } as unknown as Config; @@ -500,7 +500,7 @@ describe('DashScopeOpenAICompatibleProvider', () => { > ).mockReturnValue({ model: 'qwen-max', - disableCacheControl: true, + enableCacheControl: false, }); const result = provider.buildRequest(baseRequest, 'test-prompt-id'); diff --git a/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts b/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts index 09d2825a9..ccf201e24 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts @@ -106,8 +106,8 @@ export class DashScopeOpenAICompatibleProvider let messages = request.messages; let tools = request.tools; - // Apply DashScope cache control only if not disabled - if (!this.shouldDisableCacheControl()) { + // Apply DashScope cache control if enabled (default is enabled). + if (this.shouldEnableCacheControl()) { const { messages: updatedMessages, tools: updatedTools } = this.addDashScopeCacheControl( request, @@ -339,11 +339,12 @@ export class DashScopeOpenAICompatibleProvider /** * Check if cache control should be disabled based on configuration. * - * @returns true if cache control should be disabled, false otherwise + * @returns true if cache control should be enabled, false otherwise */ - private shouldDisableCacheControl(): boolean { + private shouldEnableCacheControl(): boolean { + // Cache control is enabled by default (when enableCacheControl is undefined or true). return ( - this.cliConfig.getContentGeneratorConfig()?.disableCacheControl === true + this.cliConfig.getContentGeneratorConfig()?.enableCacheControl !== false ); } } diff --git a/packages/core/src/utils/filesearch/fileSearch.test.ts b/packages/core/src/utils/filesearch/fileSearch.test.ts index cd4a09a8c..854944e0c 100644 --- a/packages/core/src/utils/filesearch/fileSearch.test.ts +++ b/packages/core/src/utils/filesearch/fileSearch.test.ts @@ -32,7 +32,7 @@ describe('FileSearch', () => { cache: false, cacheTtl: 0, enableRecursiveFileSearch: true, - disableFuzzySearch: false, + enableFuzzySearch: true, }); await fileSearch.initialize(); @@ -58,7 +58,7 @@ describe('FileSearch', () => { cache: false, cacheTtl: 0, enableRecursiveFileSearch: true, - disableFuzzySearch: false, + enableFuzzySearch: true, }); await fileSearch.initialize(); @@ -86,7 +86,7 @@ describe('FileSearch', () => { cache: false, cacheTtl: 0, enableRecursiveFileSearch: true, - disableFuzzySearch: false, + enableFuzzySearch: true, }); await fileSearch.initialize(); @@ -115,7 +115,7 @@ describe('FileSearch', () => { cache: false, cacheTtl: 0, enableRecursiveFileSearch: true, - disableFuzzySearch: false, + enableFuzzySearch: true, }); await fileSearch.initialize(); @@ -148,7 +148,7 @@ describe('FileSearch', () => { cache: false, cacheTtl: 0, enableRecursiveFileSearch: true, - disableFuzzySearch: false, + enableFuzzySearch: true, }); await fileSearch.initialize(); @@ -172,7 +172,7 @@ describe('FileSearch', () => { cache: false, cacheTtl: 0, enableRecursiveFileSearch: true, - disableFuzzySearch: false, + enableFuzzySearch: true, }); await fileSearch.initialize(); @@ -207,7 +207,7 @@ describe('FileSearch', () => { cache: false, cacheTtl: 0, enableRecursiveFileSearch: true, - disableFuzzySearch: false, + enableFuzzySearch: true, }); await fileSearch.initialize(); @@ -237,7 +237,7 @@ describe('FileSearch', () => { cache: false, cacheTtl: 0, enableRecursiveFileSearch: true, - disableFuzzySearch: false, + enableFuzzySearch: true, }); await fileSearch.initialize(); @@ -267,7 +267,7 @@ describe('FileSearch', () => { cache: false, cacheTtl: 0, enableRecursiveFileSearch: true, - disableFuzzySearch: false, + enableFuzzySearch: true, }); // Expect no errors to be thrown during initialization @@ -294,7 +294,7 @@ describe('FileSearch', () => { cache: false, cacheTtl: 0, enableRecursiveFileSearch: true, - disableFuzzySearch: false, + enableFuzzySearch: true, }); await fileSearch.initialize(); @@ -320,7 +320,7 @@ describe('FileSearch', () => { cache: false, cacheTtl: 0, enableRecursiveFileSearch: true, - disableFuzzySearch: false, + enableFuzzySearch: true, }); await fileSearch.initialize(); @@ -329,7 +329,7 @@ describe('FileSearch', () => { expect(results).toEqual(['src/style.css']); }); - it('should not use fzf for fuzzy matching when disableFuzzySearch is true', async () => { + it('should not use fzf for fuzzy matching when enableFuzzySearch is false', async () => { tmpDir = await createTmpDir({ src: { 'file1.js': '', @@ -346,7 +346,7 @@ describe('FileSearch', () => { cache: false, cacheTtl: 0, enableRecursiveFileSearch: true, - disableFuzzySearch: true, + enableFuzzySearch: false, }); await fileSearch.initialize(); @@ -355,7 +355,7 @@ describe('FileSearch', () => { expect(results).toEqual(['src/flexible.js']); }); - it('should use fzf for fuzzy matching when disableFuzzySearch is false', async () => { + it('should use fzf for fuzzy matching when enableFuzzySearch is true', async () => { tmpDir = await createTmpDir({ src: { 'file1.js': '', @@ -372,7 +372,7 @@ describe('FileSearch', () => { cache: false, cacheTtl: 0, enableRecursiveFileSearch: true, - disableFuzzySearch: false, + enableFuzzySearch: true, }); await fileSearch.initialize(); @@ -396,7 +396,7 @@ describe('FileSearch', () => { cache: false, cacheTtl: 0, enableRecursiveFileSearch: true, - disableFuzzySearch: false, + enableFuzzySearch: true, }); await fileSearch.initialize(); @@ -427,7 +427,7 @@ describe('FileSearch', () => { cache: false, cacheTtl: 0, enableRecursiveFileSearch: true, - disableFuzzySearch: false, + enableFuzzySearch: true, }); await expect(fileSearch.search('')).rejects.toThrow( @@ -449,7 +449,7 @@ describe('FileSearch', () => { cache: false, cacheTtl: 0, enableRecursiveFileSearch: true, - disableFuzzySearch: false, + enableFuzzySearch: true, }); await fileSearch.initialize(); @@ -472,7 +472,7 @@ describe('FileSearch', () => { cache: false, cacheTtl: 0, enableRecursiveFileSearch: true, - disableFuzzySearch: false, + enableFuzzySearch: true, }); await fileSearch.initialize(); @@ -496,7 +496,7 @@ describe('FileSearch', () => { cache: false, cacheTtl: 0, enableRecursiveFileSearch: true, - disableFuzzySearch: false, + enableFuzzySearch: true, }); await fileSearch.initialize(); @@ -533,7 +533,7 @@ describe('FileSearch', () => { cache: true, // Enable caching for this test cacheTtl: 0, enableRecursiveFileSearch: true, - disableFuzzySearch: false, + enableFuzzySearch: true, }); await fileSearch.initialize(); @@ -573,7 +573,7 @@ describe('FileSearch', () => { cache: false, cacheTtl: 0, enableRecursiveFileSearch: true, - disableFuzzySearch: false, + enableFuzzySearch: true, }); await fileSearch.initialize(); @@ -617,7 +617,7 @@ describe('FileSearch', () => { cache: true, // Ensure caching is enabled cacheTtl: 10000, enableRecursiveFileSearch: true, - disableFuzzySearch: false, + enableFuzzySearch: true, }); await fileSearch.initialize(); @@ -655,7 +655,7 @@ describe('FileSearch', () => { cache: false, cacheTtl: 0, enableRecursiveFileSearch: true, - disableFuzzySearch: false, + enableFuzzySearch: true, }); await fileSearch.initialize(); @@ -685,7 +685,7 @@ describe('FileSearch', () => { cache: false, cacheTtl: 0, enableRecursiveFileSearch: false, - disableFuzzySearch: false, + enableFuzzySearch: true, }); await fileSearch.initialize(); @@ -710,7 +710,7 @@ describe('FileSearch', () => { cache: false, cacheTtl: 0, enableRecursiveFileSearch: false, - disableFuzzySearch: false, + enableFuzzySearch: true, }); await fileSearch.initialize(); @@ -735,7 +735,7 @@ describe('FileSearch', () => { cache: false, cacheTtl: 0, enableRecursiveFileSearch: false, - disableFuzzySearch: false, + enableFuzzySearch: true, }); await fileSearch.initialize(); @@ -758,7 +758,7 @@ describe('FileSearch', () => { cache: false, cacheTtl: 0, enableRecursiveFileSearch: false, - disableFuzzySearch: false, + enableFuzzySearch: true, }); await fileSearch.initialize(); diff --git a/packages/core/src/utils/filesearch/fileSearch.ts b/packages/core/src/utils/filesearch/fileSearch.ts index 73169a1fe..9a030b436 100644 --- a/packages/core/src/utils/filesearch/fileSearch.ts +++ b/packages/core/src/utils/filesearch/fileSearch.ts @@ -22,7 +22,7 @@ export interface FileSearchOptions { cache: boolean; cacheTtl: number; enableRecursiveFileSearch: boolean; - disableFuzzySearch: boolean; + enableFuzzySearch: boolean; maxDepth?: number; } @@ -116,9 +116,11 @@ class RecursiveFileSearch implements FileSearch { pattern: string, options: SearchOptions = {}, ): Promise { + // Check if engine is properly initialized. + // If fuzzy search is enabled (or undefined, default true), fzf must be initialized. if ( !this.resultCache || - (!this.fzf && !this.options.disableFuzzySearch) || + (!this.fzf && this.options.enableFuzzySearch !== false) || !this.ignore ) { throw new Error('Engine not initialized. Call initialize() first.'); @@ -179,7 +181,8 @@ class RecursiveFileSearch implements FileSearch { private buildResultCache(): void { this.resultCache = new ResultCache(this.allFiles); - if (!this.options.disableFuzzySearch) { + // Initialize fuzzy search if enabled (or undefined, default true). + if (this.options.enableFuzzySearch !== false) { // The v1 algorithm is much faster since it only looks at the first // occurence of the pattern. We use it for search spaces that have >20k // files, because the v2 algorithm is just too slow in those cases. From 2ef9ffec198ba8836153e63bab0a251b85cc55eb Mon Sep 17 00:00:00 2001 From: Alexander Farber Date: Mon, 29 Dec 2025 23:38:48 +0100 Subject: [PATCH 11/49] Fix namings in tests --- packages/cli/src/config/config.test.ts | 8 +-- packages/cli/src/config/settings.ts | 58 +++++++++++++++++-- .../cli/src/ui/hooks/useAtCompletion.test.ts | 2 +- 3 files changed, 57 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 67d3b114b..16ec45c65 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -2045,13 +2045,13 @@ describe('loadCliConfig fileFiltering', () => { value: boolean; }> = [ { - property: 'disableFuzzySearch', - getter: (c) => c.getFileFilteringDisableFuzzySearch(), + property: 'enableFuzzySearch', + getter: (c) => c.getFileFilteringEnableFuzzySearch(), value: true, }, { - property: 'disableFuzzySearch', - getter: (c) => c.getFileFilteringDisableFuzzySearch(), + property: 'enableFuzzySearch', + getter: (c) => c.getFileFilteringEnableFuzzySearch(), value: false, }, { diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index ea41c578c..1f8e2efa6 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -229,6 +229,14 @@ function setNestedProperty( current[lastKey] = value; } +// Dynamically determine the top-level keys from the V2 settings structure. +const KNOWN_V2_CONTAINERS = new Set([ + ...Object.values(MIGRATION_MAP).map((path) => path.split('.')[0]), + ...Object.values(INVERTED_BOOLEAN_MIGRATIONS).map( + (path) => path.split('.')[0], + ), +]); + export function needsMigration(settings: Record): boolean { // Check version field first - if present and matches current version, no migration needed if (SETTINGS_VERSION_KEY in settings) { @@ -410,9 +418,26 @@ const REVERSE_MIGRATION_MAP: Record = Object.fromEntries( Object.entries(MIGRATION_MAP).map(([key, value]) => [value, key]), ); -// Dynamically determine the top-level keys from the V2 settings structure. -const KNOWN_V2_CONTAINERS = new Set( - Object.values(MIGRATION_MAP).map((path) => path.split('.')[0]), +// Reverse map for old V2 paths (before rename) to V1 keys. +// Used when migrating settings that still have old V2 naming (e.g., general.disableAutoUpdate). +const OLD_V2_TO_V1_MAP: Record = {}; +for (const [oldV2Path, newV3Path] of Object.entries(INVERTED_V2_PATHS)) { + // Find the V1 key that maps to this V3 path + for (const [v1Key, v3Path] of Object.entries(INVERTED_BOOLEAN_MIGRATIONS)) { + if (v3Path === newV3Path) { + OLD_V2_TO_V1_MAP[oldV2Path] = v1Key; + break; + } + } +} + +// Reverse map for new V3 paths to V1 keys (with boolean inversion). +// Used when migrating settings that have new V3 naming (e.g., general.enableAutoUpdate). +const V3_TO_V1_INVERTED_MAP: Record = Object.fromEntries( + Object.entries(INVERTED_BOOLEAN_MIGRATIONS).map(([v1Key, v3Path]) => [ + v3Path, + v1Key, + ]), ); function getSettingsFileKeyWarnings( @@ -451,7 +476,7 @@ function getSettingsFileKeyWarnings( ignoredLegacyKeys.add(oldKey); warnings.push( - `⚠️ Legacy setting '${oldKey}' will be ignored in ${settingsFilePath}. Please use '${newPath}' instead.`, + `Warning: Legacy setting '${oldKey}' will be ignored in ${settingsFilePath}. Please use '${newPath}' instead.`, ); } @@ -469,7 +494,7 @@ function getSettingsFileKeyWarnings( } warnings.push( - `⚠️ Unknown setting '${key}' will be ignored in ${settingsFilePath}.`, + `Warning: Unknown setting '${key}' will be ignored in ${settingsFilePath}.`, ); } @@ -488,7 +513,8 @@ export function getSettingsWarnings(loadedSettings: LoadedSettings): string[] { for (const scope of [SettingScope.User, SettingScope.Workspace]) { const settingsFile = loadedSettings.forScope(scope); if (settingsFile.rawJson === undefined) { - continue; // File not present / not loaded. + continue; + // File not present / not loaded. } const settingsObject = settingsFile.originalSettings as unknown as Record< string, @@ -520,6 +546,26 @@ export function migrateSettingsToV1( } } + // Handle old V2 inverted paths (no value inversion needed) + // e.g., general.disableAutoUpdate -> disableAutoUpdate + for (const [oldV2Path, v1Key] of Object.entries(OLD_V2_TO_V1_MAP)) { + const value = getNestedProperty(v2Settings, oldV2Path); + if (value !== undefined) { + v1Settings[v1Key] = value; + v2Keys.delete(oldV2Path.split('.')[0]); + } + } + + // Handle new V3 inverted paths (WITH value inversion) + // e.g., general.enableAutoUpdate -> disableAutoUpdate (inverted) + for (const [v3Path, v1Key] of Object.entries(V3_TO_V1_INVERTED_MAP)) { + const value = getNestedProperty(v2Settings, v3Path); + if (value !== undefined && typeof value === 'boolean') { + v1Settings[v1Key] = !value; + v2Keys.delete(v3Path.split('.')[0]); + } + } + // Preserve mcpServers at the top level if (v2Settings['mcpServers']) { v1Settings['mcpServers'] = v2Settings['mcpServers']; diff --git a/packages/cli/src/ui/hooks/useAtCompletion.test.ts b/packages/cli/src/ui/hooks/useAtCompletion.test.ts index e379b87b0..1c7795453 100644 --- a/packages/cli/src/ui/hooks/useAtCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useAtCompletion.test.ts @@ -197,7 +197,7 @@ describe('useAtCompletion', () => { cache: false, cacheTtl: 0, enableRecursiveFileSearch: true, - disableFuzzySearch: false, + enableFuzzySearch: true, }); await realFileSearch.initialize(); From d4fa2e7dd040ba92401a6434c593e2591a85ba9a Mon Sep 17 00:00:00 2001 From: Alexander Farber Date: Mon, 29 Dec 2025 23:45:09 +0100 Subject: [PATCH 12/49] Fix migration of consolidated settings --- packages/cli/src/config/settings.ts | 71 ++++++++++++++++++++++++++++- 1 file changed, 70 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 1f8e2efa6..77cc3cd1b 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -135,6 +135,12 @@ const INVERTED_BOOLEAN_MIGRATIONS: Record = { disableCacheControl: 'model.generationConfig.enableCacheControl', }; +// Consolidated settings: multiple old V1 keys that map to a single new key. +// Policy: if ANY of the old disable* settings is true, the new enable* should be false. +const CONSOLIDATED_SETTINGS: Record = { + 'general.enableAutoUpdate': ['disableAutoUpdate', 'disableUpdateNag'], +}; + // V2 nested paths that need inversion when migrating to V3 const INVERTED_V2_PATHS: Record = { 'general.disableAutoUpdate': 'general.enableAutoUpdate', @@ -147,6 +153,15 @@ const INVERTED_V2_PATHS: Record = { 'model.generationConfig.enableCacheControl', }; +// Consolidated V2 paths: multiple old paths that map to a single new path. +// Policy: if ANY of the old disable* settings is true, the new enable* should be false. +const CONSOLIDATED_V2_PATHS: Record = { + 'general.enableAutoUpdate': [ + 'general.disableAutoUpdate', + 'general.disableUpdateNag', + ], +}; + export function getSystemSettingsPath(): string { if (process.env['QWEN_CODE_SYSTEM_SETTINGS_PATH']) { return process.env['QWEN_CODE_SYSTEM_SETTINGS_PATH']; @@ -300,8 +315,34 @@ function migrateSettingsToV2( } } - // Handle V1 settings that need boolean inversion (disable* -> enable*) + // Handle consolidated settings first (multiple old keys -> single new key) + // Policy: if ANY of the old disable* settings is true, the new enable* should be false + for (const [newPath, oldKeys] of Object.entries(CONSOLIDATED_SETTINGS)) { + let hasAnyDisable = false; + let hasAnyValue = false; + for (const oldKey of oldKeys) { + if (flatKeys.has(oldKey)) { + hasAnyValue = true; + const oldValue = flatSettings[oldKey]; + if (typeof oldValue === 'boolean' && oldValue === true) { + hasAnyDisable = true; + } + flatKeys.delete(oldKey); + } + } + if (hasAnyValue) { + // enableAutoUpdate = !hasAnyDisable (if any disable* was true, enable should be false) + setNestedProperty(v2Settings, newPath, !hasAnyDisable); + } + } + + // Handle remaining V1 settings that need boolean inversion (disable* -> enable*) + // Skip keys that were already handled by consolidated settings + const consolidatedKeys = new Set(Object.values(CONSOLIDATED_SETTINGS).flat()); for (const [oldKey, newPath] of Object.entries(INVERTED_BOOLEAN_MIGRATIONS)) { + if (consolidatedKeys.has(oldKey)) { + continue; + } if (flatKeys.has(oldKey)) { const oldValue = flatSettings[oldKey]; if (typeof oldValue === 'boolean') { @@ -360,8 +401,36 @@ function migrateV2ToV3( let changed = false; const result = structuredClone(settings); + const processedPaths = new Set(); + // Handle consolidated V2 paths first (multiple old paths -> single new path) + // Policy: if ANY of the old disable* settings is true, the new enable* should be false + for (const [newPath, oldPaths] of Object.entries(CONSOLIDATED_V2_PATHS)) { + let hasAnyDisable = false; + let hasAnyValue = false; + for (const oldPath of oldPaths) { + const oldValue = getNestedProperty(result, oldPath); + if (typeof oldValue === 'boolean') { + hasAnyValue = true; + if (oldValue === true) { + hasAnyDisable = true; + } + deleteNestedProperty(result, oldPath); + processedPaths.add(oldPath); + changed = true; + } + } + if (hasAnyValue) { + // enableAutoUpdate = !hasAnyDisable (if any disable* was true, enable should be false) + setNestedProperty(result, newPath, !hasAnyDisable); + } + } + + // Handle remaining V2 paths that need inversion for (const [oldPath, newPath] of Object.entries(INVERTED_V2_PATHS)) { + if (processedPaths.has(oldPath)) { + continue; + } const oldValue = getNestedProperty(result, oldPath); if (typeof oldValue === 'boolean') { // Remove old property From fd4157a6a160d75ec9562df8737cd8b6c6c27f25 Mon Sep 17 00:00:00 2001 From: Alexander Farber Date: Mon, 29 Dec 2025 23:47:06 +0100 Subject: [PATCH 13/49] Test migration of consolidated settings --- packages/cli/src/config/settings.test.ts | 69 ++++++++++++++++++++++++ packages/cli/src/config/settings.ts | 7 ++- 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts index 3f3980c10..0893ca67a 100644 --- a/packages/cli/src/config/settings.test.ts +++ b/packages/cli/src/config/settings.test.ts @@ -643,6 +643,75 @@ describe('Settings Loading and Merging', () => { expect(writtenContent[SETTINGS_VERSION_KEY]).toBe(SETTINGS_VERSION); }); + it('should consolidate disableAutoUpdate and disableUpdateNag - both false means enableAutoUpdate is true', () => { + (mockFsExistsSync as Mock).mockImplementation( + (p: fs.PathLike) => p === USER_SETTINGS_PATH, + ); + // V1 settings with both disable* settings as false + const legacySettingsContent = { + disableAutoUpdate: false, + disableUpdateNag: false, + }; + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === USER_SETTINGS_PATH) + return JSON.stringify(legacySettingsContent); + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + + // Both are false, so enableAutoUpdate should be true + expect(settings.merged.general?.enableAutoUpdate).toBe(true); + }); + + it('should consolidate disableAutoUpdate and disableUpdateNag - any true means enableAutoUpdate is false', () => { + (mockFsExistsSync as Mock).mockImplementation( + (p: fs.PathLike) => p === USER_SETTINGS_PATH, + ); + // V1 settings with disableAutoUpdate=false but disableUpdateNag=true + const legacySettingsContent = { + disableAutoUpdate: false, + disableUpdateNag: true, + }; + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === USER_SETTINGS_PATH) + return JSON.stringify(legacySettingsContent); + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + + // disableUpdateNag is true, so enableAutoUpdate should be false + expect(settings.merged.general?.enableAutoUpdate).toBe(false); + }); + + it('should consolidate disableAutoUpdate and disableUpdateNag - disableAutoUpdate=true takes precedence', () => { + (mockFsExistsSync as Mock).mockImplementation( + (p: fs.PathLike) => p === USER_SETTINGS_PATH, + ); + // V1 settings with disableAutoUpdate=true + const legacySettingsContent = { + disableAutoUpdate: true, + disableUpdateNag: false, + }; + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === USER_SETTINGS_PATH) + return JSON.stringify(legacySettingsContent); + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + + // disableAutoUpdate is true, so enableAutoUpdate should be false + expect(settings.merged.general?.enableAutoUpdate).toBe(false); + }); + it('should correctly merge and migrate legacy array properties from multiple scopes', () => { (mockFsExistsSync as Mock).mockReturnValue(true); const legacyUserSettings = { diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 77cc3cd1b..8ad830c9b 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -280,7 +280,12 @@ export function needsMigration(settings: Record): boolean { return true; }); - return hasV1Keys; + // Also check for old inverted boolean keys (disable* -> enable*) + const hasInvertedBooleanKeys = Object.keys(INVERTED_BOOLEAN_MIGRATIONS).some( + (v1Key) => v1Key in settings, + ); + + return hasV1Keys || hasInvertedBooleanKeys; } function migrateSettingsToV2( From 557c99a08bea83165d542541c61b2b6be4a3eb49 Mon Sep 17 00:00:00 2001 From: Alexander Farber Date: Tue, 30 Dec 2025 08:00:08 +0100 Subject: [PATCH 14/49] Rename disableAutoUpdate to isAutoUpdateEnabled, update snapshots --- .../cli/src/utils/installationInfo.test.ts | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/utils/installationInfo.test.ts b/packages/cli/src/utils/installationInfo.test.ts index 34a70dae2..6eeb45032 100644 --- a/packages/cli/src/utils/installationInfo.test.ts +++ b/packages/cli/src/utils/installationInfo.test.ts @@ -178,13 +178,15 @@ describe('getInstallationInfo', () => { throw new Error('Command failed'); }); - const info = getInstallationInfo(projectRoot, false); + // isAutoUpdateEnabled = true -> "Attempting to automatically update" + const info = getInstallationInfo(projectRoot, true); expect(info.packageManager).toBe(PackageManager.PNPM); expect(info.isGlobal).toBe(true); expect(info.updateCommand).toBe('pnpm add -g @qwen-code/qwen-code@latest'); expect(info.updateMessage).toContain('Attempting to automatically update'); - const infoDisabled = getInstallationInfo(projectRoot, true); + // isAutoUpdateEnabled = false -> "Please run..." + const infoDisabled = getInstallationInfo(projectRoot, false); expect(infoDisabled.updateMessage).toContain('Please run pnpm add'); }); @@ -196,7 +198,8 @@ describe('getInstallationInfo', () => { throw new Error('Command failed'); }); - const info = getInstallationInfo(projectRoot, false); + // isAutoUpdateEnabled = true -> "Attempting to automatically update" + const info = getInstallationInfo(projectRoot, true); expect(info.packageManager).toBe(PackageManager.YARN); expect(info.isGlobal).toBe(true); expect(info.updateCommand).toBe( @@ -204,7 +207,8 @@ describe('getInstallationInfo', () => { ); expect(info.updateMessage).toContain('Attempting to automatically update'); - const infoDisabled = getInstallationInfo(projectRoot, true); + // isAutoUpdateEnabled = false -> "Please run..." + const infoDisabled = getInstallationInfo(projectRoot, false); expect(infoDisabled.updateMessage).toContain('Please run yarn global add'); }); @@ -216,13 +220,15 @@ describe('getInstallationInfo', () => { throw new Error('Command failed'); }); - const info = getInstallationInfo(projectRoot, false); + // isAutoUpdateEnabled = true -> "Attempting to automatically update" + const info = getInstallationInfo(projectRoot, true); expect(info.packageManager).toBe(PackageManager.BUN); expect(info.isGlobal).toBe(true); expect(info.updateCommand).toBe('bun add -g @qwen-code/qwen-code@latest'); expect(info.updateMessage).toContain('Attempting to automatically update'); - const infoDisabled = getInstallationInfo(projectRoot, true); + // isAutoUpdateEnabled = false -> "Please run..." + const infoDisabled = getInstallationInfo(projectRoot, false); expect(infoDisabled.updateMessage).toContain('Please run bun add'); }); @@ -301,7 +307,8 @@ describe('getInstallationInfo', () => { throw new Error('Command failed'); }); - const info = getInstallationInfo(projectRoot, false); + // isAutoUpdateEnabled = true -> "Attempting to automatically update" + const info = getInstallationInfo(projectRoot, true); expect(info.packageManager).toBe(PackageManager.NPM); expect(info.isGlobal).toBe(true); expect(info.updateCommand).toBe( @@ -309,7 +316,8 @@ describe('getInstallationInfo', () => { ); expect(info.updateMessage).toContain('Attempting to automatically update'); - const infoDisabled = getInstallationInfo(projectRoot, true); + // isAutoUpdateEnabled = false -> "Please run..." + const infoDisabled = getInstallationInfo(projectRoot, false); expect(infoDisabled.updateMessage).toContain('Please run npm install'); }); }); From 2c25b2fabdb44f5774c59d0cdb2cebc6d712e0df Mon Sep 17 00:00:00 2001 From: Alexander Farber Date: Tue, 30 Dec 2025 08:21:58 +0100 Subject: [PATCH 15/49] Mock getFileFilteringEnableFuzzySearch --- packages/cli/src/ui/hooks/useAtCompletion.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/ui/hooks/useAtCompletion.test.ts b/packages/cli/src/ui/hooks/useAtCompletion.test.ts index 1c7795453..588d53fcf 100644 --- a/packages/cli/src/ui/hooks/useAtCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useAtCompletion.test.ts @@ -49,7 +49,7 @@ describe('useAtCompletion', () => { respectQwenIgnore: true, })), getEnableRecursiveFileSearch: () => true, - getFileFilteringDisableFuzzySearch: () => false, + getFileFilteringEnableFuzzySearch: () => true, } as unknown as Config; vi.clearAllMocks(); }); @@ -479,7 +479,7 @@ describe('useAtCompletion', () => { respectGitIgnore: true, respectQwenIgnore: true, })), - getFileFilteringDisableFuzzySearch: () => false, + getFileFilteringEnableFuzzySearch: () => true, } as unknown as Config; const { result } = renderHook(() => From 895cbf71cdf1b7b0d43138f87260481e5c99b49b Mon Sep 17 00:00:00 2001 From: Alexander Farber Date: Tue, 30 Dec 2025 09:03:50 +0100 Subject: [PATCH 16/49] Bump settings version to V3 even when no migration changes needed --- packages/cli/src/config/settings.test.ts | 30 ++++++++++++++++++++++++ packages/cli/src/config/settings.ts | 6 +++++ 2 files changed, 36 insertions(+) diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts index 0893ca67a..8d8872b61 100644 --- a/packages/cli/src/config/settings.test.ts +++ b/packages/cli/src/config/settings.test.ts @@ -712,6 +712,36 @@ describe('Settings Loading and Merging', () => { expect(settings.merged.general?.enableAutoUpdate).toBe(false); }); + it('should bump version to 3 even when V2 settings already have V3-compatible content', () => { + (mockFsExistsSync as Mock).mockImplementation( + (p: fs.PathLike) => p === USER_SETTINGS_PATH, + ); + // V2 settings that already have V3-compatible keys (no migration needed) + const v2SettingsWithV3Content = { + $version: 2, + general: { + enableAutoUpdate: true, + }, + }; + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === USER_SETTINGS_PATH) + return JSON.stringify(v2SettingsWithV3Content); + return '{}'; + }, + ); + + loadSettings(MOCK_WORKSPACE_DIR); + + // Version should be bumped to 3 even though no keys needed migration + const writeCall = (fs.writeFileSync as Mock).mock.calls.find( + (call: unknown[]) => call[0] === USER_SETTINGS_PATH, + ); + expect(writeCall).toBeDefined(); + const writtenContent = JSON.parse(writeCall[1] as string); + expect(writtenContent.$version).toBe(SETTINGS_VERSION); + }); + it('should correctly merge and migrate legacy array properties from multiple scopes', () => { (mockFsExistsSync as Mock).mockReturnValue(true); const legacyUserSettings = { diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 8ad830c9b..01110d7dd 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -451,6 +451,12 @@ function migrateV2ToV3( return result; } + // Even if no changes, bump version to 3 to skip future migration checks + if (typeof version === 'number' && version < SETTINGS_VERSION) { + result[SETTINGS_VERSION_KEY] = SETTINGS_VERSION; + return result; + } + return null; } From 273caf1cde0a40eaafbb64161665409bf27f128b Mon Sep 17 00:00:00 2001 From: Alexander Farber Date: Wed, 21 Jan 2026 11:43:20 +0100 Subject: [PATCH 17/49] Rename migrateSettingsToV2 to migrateV1ToLatest --- packages/cli/src/config/settings.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 01110d7dd..02de5db0d 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -288,7 +288,12 @@ export function needsMigration(settings: Record): boolean { return hasV1Keys || hasInvertedBooleanKeys; } -function migrateSettingsToV2( +/** + * Migrates V1 (flat) settings directly to the latest version (V3). + * This includes both structural migration (flat -> nested) and boolean + * inversion (disable* -> enable*), so migrateV2ToV3 will be skipped. + */ +function migrateV1ToLatest( flatSettings: Record, ): Record | null { if (!needsMigration(flatSettings)) { @@ -943,7 +948,7 @@ export function loadSettings( let settingsObject = rawSettings as Record; if (needsMigration(settingsObject)) { - const migratedSettings = migrateSettingsToV2(settingsObject); + const migratedSettings = migrateV1ToLatest(settingsObject); if (migratedSettings) { if (MIGRATE_V2_OVERWRITE) { try { From efb1eb37a2d16e77605d569246a1032943dbdf5c Mon Sep 17 00:00:00 2001 From: Alexander Farber Date: Wed, 21 Jan 2026 11:47:38 +0100 Subject: [PATCH 18/49] Rename to migrateV1ToV3 --- packages/cli/src/config/settings.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 02de5db0d..089bb3ef6 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -289,11 +289,11 @@ export function needsMigration(settings: Record): boolean { } /** - * Migrates V1 (flat) settings directly to the latest version (V3). + * Migrates V1 (flat) settings directly to V3. * This includes both structural migration (flat -> nested) and boolean * inversion (disable* -> enable*), so migrateV2ToV3 will be skipped. */ -function migrateV1ToLatest( +function migrateV1ToV3( flatSettings: Record, ): Record | null { if (!needsMigration(flatSettings)) { @@ -948,7 +948,7 @@ export function loadSettings( let settingsObject = rawSettings as Record; if (needsMigration(settingsObject)) { - const migratedSettings = migrateV1ToLatest(settingsObject); + const migratedSettings = migrateV1ToV3(settingsObject); if (migratedSettings) { if (MIGRATE_V2_OVERWRITE) { try { From 5b0f2b3b0ebe36ddb2817aaa572dd746872e1590 Mon Sep 17 00:00:00 2001 From: Alexander Farber Date: Wed, 21 Jan 2026 13:58:05 +0100 Subject: [PATCH 19/49] Rename disableCacheControl to enableCacheControl in core package --- packages/core/src/config/config.ts | 11 ++++++----- packages/core/src/models/constants.ts | 2 +- packages/core/src/models/modelsConfig.ts | 6 +++--- packages/core/src/models/types.ts | 2 +- 4 files changed, 11 insertions(+), 10 deletions(-) diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index cb4da3bca..87f1a3a0f 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -923,9 +923,10 @@ export class Config { // Hot-update fields (qwen-oauth models share the same auth + client). this.contentGeneratorConfig.model = config.model; this.contentGeneratorConfig.samplingParams = config.samplingParams; - this.contentGeneratorConfig.disableCacheControl = - config.disableCacheControl; + config.disableCacheControl; this.contentGeneratorConfig.contextWindowSize = config.contextWindowSize; + this.contentGeneratorConfig.enableCacheControl = + config.enableCacheControl; if ('model' in sources) { this.contentGeneratorConfigSources['model'] = sources['model']; @@ -934,9 +935,9 @@ export class Config { this.contentGeneratorConfigSources['samplingParams'] = sources['samplingParams']; } - if ('disableCacheControl' in sources) { - this.contentGeneratorConfigSources['disableCacheControl'] = - sources['disableCacheControl']; + if ('enableCacheControl' in sources) { + this.contentGeneratorConfigSources['enableCacheControl'] = + sources['enableCacheControl']; } if ('contextWindowSize' in sources) { this.contentGeneratorConfigSources['contextWindowSize'] = diff --git a/packages/core/src/models/constants.ts b/packages/core/src/models/constants.ts index 5bc80fef2..9b4cc2ce7 100644 --- a/packages/core/src/models/constants.ts +++ b/packages/core/src/models/constants.ts @@ -22,7 +22,7 @@ export const MODEL_GENERATION_CONFIG_FIELDS = [ 'samplingParams', 'timeout', 'maxRetries', - 'disableCacheControl', + 'enableCacheControl', 'schemaCompliance', 'reasoning', 'contextWindowSize', diff --git a/packages/core/src/models/modelsConfig.ts b/packages/core/src/models/modelsConfig.ts index 74f7d250c..3b13add50 100644 --- a/packages/core/src/models/modelsConfig.ts +++ b/packages/core/src/models/modelsConfig.ts @@ -559,12 +559,12 @@ export class ModelsConfig { detail: 'generationConfig.maxRetries', }; - this._generationConfig.disableCacheControl = gc.disableCacheControl; - this.generationConfigSources['disableCacheControl'] = { + this._generationConfig.enableCacheControl = gc.enableCacheControl; + this.generationConfigSources['enableCacheControl'] = { kind: 'modelProviders', authType: model.authType, modelId: model.id, - detail: 'generationConfig.disableCacheControl', + detail: 'generationConfig.enableCacheControl', }; this._generationConfig.schemaCompliance = gc.schemaCompliance; diff --git a/packages/core/src/models/types.ts b/packages/core/src/models/types.ts index 1a4d0c897..ebd833cd4 100644 --- a/packages/core/src/models/types.ts +++ b/packages/core/src/models/types.ts @@ -28,7 +28,7 @@ export type ModelGenerationConfig = Pick< | 'samplingParams' | 'timeout' | 'maxRetries' - | 'disableCacheControl' + | 'enableCacheControl' | 'schemaCompliance' | 'reasoning' | 'customHeaders' From 0bcc5a96f4966577d0ab41562fef4ff8243c2f1a Mon Sep 17 00:00:00 2001 From: Mingholy Date: Sun, 1 Feb 2026 17:32:08 +0800 Subject: [PATCH 20/49] docs: add settings migration instructions --- docs/users/configuration/settings.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index dfd70cdcd..c73b96ded 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -45,6 +45,32 @@ In addition to a project settings file, a project's `.qwen` directory can contai - [Custom sandbox profiles](../features/sandbox) (e.g. `.qwen/sandbox-macos-custom.sb`, `.qwen/sandbox.Dockerfile`). - [Agent Skills](../features/skills) (experimental) under `.qwen/skills/` (each Skill is a directory containing a `SKILL.md`). +### Configuration migration + +Qwen Code automatically migrates legacy configuration settings to the new format. Old settings files are backed up before migration. The following settings have been renamed from negative (`disable*`) to positive (`enable*`) naming: + +| Old Setting | New Setting | Notes | +| ---------------------------------------------------- | ----------------------------------------------------- | ------------------------------------ | +| `disableAutoUpdate` + `disableUpdateNag` | `general.enableAutoUpdate` | Consolidated into a single setting | +| `disableLoadingPhrases` | `ui.accessibility.enableLoadingPhrases` | | +| `disableFuzzySearch` | `context.fileFiltering.enableFuzzySearch` | | +| `disableCacheControl` | `model.generationConfig.enableCacheControl` | | + +> [!note] +> +> **Boolean value inversion:** When migrating, boolean values are inverted (e.g., `disableAutoUpdate: true` becomes `enableAutoUpdate: false`). + +#### Consolidation policy for `disableAutoUpdate` and `disableUpdateNag` + +When both legacy settings are present with different values, the migration follows this policy: if **either** `disableAutoUpdate` **or** `disableUpdateNag` is `true`, then `enableAutoUpdate` becomes `false`: + +| `disableAutoUpdate` | `disableUpdateNag` | Migrated `enableAutoUpdate` | +| ------------------- | ------------------ | --------------------------- | +| `false` | `false` | `true` | +| `false` | `true` | `false` | +| `true` | `false` | `false` | +| `true` | `true` | `false` | + ### Available settings in `settings.json` Settings are organized into categories. All settings should be placed within their corresponding top-level category object in your `settings.json` file. From 12c8883f55c2d50ade0b96c297333257a6746fb6 Mon Sep 17 00:00:00 2001 From: Mingholy Date: Sun, 1 Feb 2026 18:09:58 +0800 Subject: [PATCH 21/49] fix: typo and stale tests --- packages/core/src/config/config.test.ts | 10 +++++----- packages/core/src/config/config.ts | 1 - 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index e6a87941e..7662e8fe0 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -1337,7 +1337,7 @@ describe('Model Switching and Config Updates', () => { ['apiKey']: 'test-key', ['contextWindowSize']: 1_000_000, ['samplingParams']: { temperature: 0.7 }, - ['disableCacheControl']: false, + ['enableCacheControl']: true, }; vi.mocked(resolveContentGeneratorConfigWithSources).mockReturnValue({ @@ -1362,7 +1362,7 @@ describe('Model Switching and Config Updates', () => { ['apiKey']: 'test-key', ['contextWindowSize']: 128_000, ['samplingParams']: { temperature: 0.8 }, - ['disableCacheControl']: true, + ['enableCacheControl']: false, }; vi.mocked(resolveContentGeneratorConfigWithSources).mockReturnValue({ @@ -1371,7 +1371,7 @@ describe('Model Switching and Config Updates', () => { model: { kind: 'programmatic', detail: 'user' }, contextWindowSize: { kind: 'computed', detail: 'auto' }, samplingParams: { kind: 'settings' }, - disableCacheControl: { kind: 'settings' }, + enableCacheControl: { kind: 'settings' }, }, }); @@ -1390,7 +1390,7 @@ describe('Model Switching and Config Updates', () => { expect(updatedConfig['model']).toBe('qwen-max'); expect(updatedConfig['contextWindowSize']).toBe(128_000); expect(updatedConfig['samplingParams']?.temperature).toBe(0.8); - expect(updatedConfig['disableCacheControl']).toBe(true); + expect(updatedConfig['enableCacheControl']).toBe(false); // Verify sources are also updated const sources = config.getContentGeneratorConfigSources(); @@ -1399,7 +1399,7 @@ describe('Model Switching and Config Updates', () => { expect(sources['contextWindowSize']?.kind).toBe('computed'); expect(sources['contextWindowSize']?.detail).toBe('auto'); expect(sources['samplingParams']?.kind).toBe('settings'); - expect(sources['disableCacheControl']?.kind).toBe('settings'); + expect(sources['enableCacheControl']?.kind).toBe('settings'); }); it('should trigger full refresh when switching to non-qwen-oauth provider', async () => { diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 87f1a3a0f..894d174f5 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -923,7 +923,6 @@ export class Config { // Hot-update fields (qwen-oauth models share the same auth + client). this.contentGeneratorConfig.model = config.model; this.contentGeneratorConfig.samplingParams = config.samplingParams; - config.disableCacheControl; this.contentGeneratorConfig.contextWindowSize = config.contextWindowSize; this.contentGeneratorConfig.enableCacheControl = config.enableCacheControl; From 889c49f0f43f08829303a2f70fea256969b89a12 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Tue, 20 Jan 2026 17:29:45 +0800 Subject: [PATCH 22/49] fix: provide available models of all configured authTypes --- packages/cli/src/acp-integration/acpAgent.ts | 19 ++++- .../acp-integration/session/Session.test.ts | 46 +++++++---- .../src/acp-integration/session/Session.ts | 37 +++++++-- .../src/ui/components/ModelDialog.test.tsx | 66 ++++++++++------ .../cli/src/ui/components/ModelDialog.tsx | 31 ++++---- packages/cli/src/utils/acpModelUtils.test.ts | 42 ++++++++++ packages/cli/src/utils/acpModelUtils.ts | 55 +++++++++++++ packages/core/src/config/config.ts | 8 ++ packages/core/src/models/modelsConfig.test.ts | 77 +++++++++++++++++-- packages/core/src/models/modelsConfig.ts | 37 +++++++-- 10 files changed, 344 insertions(+), 74 deletions(-) create mode 100644 packages/cli/src/utils/acpModelUtils.test.ts create mode 100644 packages/cli/src/utils/acpModelUtils.ts diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 12cf69890..1c766e7a2 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -32,6 +32,10 @@ import { loadCliConfig } from '../config/config.js'; // Import the modular Session class import { Session } from './session/Session.js'; +import { + formatAcpModelId, + parseAcpBaseModelId, +} from '../utils/acpModelUtils.js'; export async function runAcpAgent( config: Config, @@ -367,15 +371,24 @@ class GeminiAgent { private buildAvailableModels( config: Config, ): acp.NewSessionResponse['models'] { - const currentModelId = ( + const rawCurrentModelId = ( config.getModel() || this.config.getModel() || '' ).trim(); - const availableModels = config.getAvailableModels(); + const currentAuthType = config.getAuthType(); + const allConfiguredModels = config.getAllConfiguredModels(); + + const baseCurrentModelId = parseAcpBaseModelId(rawCurrentModelId); + const currentModelId = + currentAuthType && baseCurrentModelId + ? formatAcpModelId(baseCurrentModelId, currentAuthType) + : baseCurrentModelId; + + const availableModels = allConfiguredModels; const mappedAvailableModels = availableModels.map((model) => ({ - modelId: model.id, + modelId: formatAcpModelId(model.id, model.authType), name: model.label, description: model.description ?? null, _meta: { diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index 5f37e1103..401d459cf 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -10,7 +10,7 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { Session } from './Session.js'; import type { Config, GeminiChat } from '@qwen-code/qwen-code-core'; -import { ApprovalMode } from '@qwen-code/qwen-code-core'; +import { ApprovalMode, AuthType } from '@qwen-code/qwen-code-core'; import type * as acp from '../acp.js'; import type { LoadedSettings } from '../../config/settings.js'; import * as nonInteractiveCliCommands from '../../nonInteractiveCliCommands.js'; @@ -27,14 +27,19 @@ describe('Session', () => { let mockSettings: LoadedSettings; let session: Session; let currentModel: string; - let setModelSpy: ReturnType; + let currentAuthType: AuthType; + let switchModelSpy: ReturnType; let getAvailableCommandsSpy: ReturnType; beforeEach(() => { currentModel = 'qwen3-code-plus'; - setModelSpy = vi.fn().mockImplementation(async (modelId: string) => { - currentModel = modelId; - }); + currentAuthType = AuthType.USE_OPENAI; + switchModelSpy = vi + .fn() + .mockImplementation(async (authType: AuthType, modelId: string) => { + currentAuthType = authType; + currentModel = modelId; + }); mockChat = { sendMessageStream: vi.fn(), @@ -46,7 +51,7 @@ describe('Session', () => { mockConfig = { setApprovalMode: vi.fn(), - setModel: setModelSpy, + switchModel: switchModelSpy, getModel: vi.fn().mockImplementation(() => currentModel), getSessionId: vi.fn().mockReturnValue('test-session-id'), getTelemetryLogPromptsEnabled: vi.fn().mockReturnValue(false), @@ -62,6 +67,7 @@ describe('Session', () => { getEnableRecursiveFileSearch: vi.fn().mockReturnValue(false), getTargetDir: vi.fn().mockReturnValue(process.cwd()), getDebugMode: vi.fn().mockReturnValue(false), + getAuthType: vi.fn().mockImplementation(() => currentAuthType), } as unknown as Config; mockClient = { @@ -108,17 +114,25 @@ describe('Session', () => { describe('setModel', () => { it('sets model via config and returns current model', async () => { + const requested = `qwen3-coder-plus(${AuthType.USE_OPENAI})`; const result = await session.setModel({ sessionId: 'test-session-id', - modelId: ' qwen3-coder-plus ', + modelId: ` ${requested} `, }); - expect(mockConfig.setModel).toHaveBeenCalledWith('qwen3-coder-plus', { - reason: 'user_request_acp', - context: 'session/set_model', - }); + expect(mockConfig.switchModel).toHaveBeenCalledWith( + AuthType.USE_OPENAI, + 'qwen3-coder-plus', + undefined, + { + reason: 'user_request_acp', + context: 'session/set_model', + }, + ); expect(mockConfig.getModel).toHaveBeenCalled(); - expect(result).toEqual({ modelId: 'qwen3-coder-plus' }); + expect(result).toEqual({ + modelId: `qwen3-coder-plus(${AuthType.USE_OPENAI})`, + }); }); it('rejects empty/whitespace model IDs', async () => { @@ -129,17 +143,17 @@ describe('Session', () => { }), ).rejects.toThrow('Invalid params'); - expect(mockConfig.setModel).not.toHaveBeenCalled(); + expect(mockConfig.switchModel).not.toHaveBeenCalled(); }); - it('propagates errors from config.setModel', async () => { + it('propagates errors from config.switchModel', async () => { const configError = new Error('Invalid model'); - setModelSpy.mockRejectedValueOnce(configError); + switchModelSpy.mockRejectedValueOnce(configError); await expect( session.setModel({ sessionId: 'test-session-id', - modelId: 'invalid-model', + modelId: `invalid-model(${AuthType.USE_OPENAI})`, }), ).rejects.toThrow('Invalid model'); }); diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 48d91fd0e..a819ccd2a 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -19,6 +19,7 @@ import type { SubAgentEventEmitter, } from '@qwen-code/qwen-code-core'; import { + AuthType, ApprovalMode, convertToFunctionResponse, DiscoveredMCPTool, @@ -58,6 +59,10 @@ import type { CurrentModeUpdate, } from '../schema.js'; import { isSlashCommand } from '../../ui/utils/commandUtils.js'; +import { + formatAcpModelId, + parseAcpModelOption, +} from '../../utils/acpModelUtils.js'; // Import modular session components import type { SessionContext, ToolCallStartParams } from './types.js'; @@ -355,23 +360,39 @@ export class Session implements SessionContext { * Validates the model ID and switches the model via Config. */ async setModel(params: SetModelRequest): Promise { - const modelId = params.modelId.trim(); + const rawModelId = params.modelId.trim(); - if (!modelId) { + if (!rawModelId) { throw acp.RequestError.invalidParams('modelId cannot be empty'); } - // Attempt to set the model using config - await this.config.setModel(modelId, { - reason: 'user_request_acp', - context: 'session/set_model', - }); + const parsed = parseAcpModelOption(rawModelId); + const previousAuthType = this.config.getAuthType?.(); + const selectedAuthType = parsed.authType ?? previousAuthType; + + if (!selectedAuthType) { + throw acp.RequestError.invalidParams('authType cannot be determined'); + } + + await this.config.switchModel( + selectedAuthType, + parsed.modelId, + selectedAuthType !== previousAuthType && + selectedAuthType === AuthType.QWEN_OAUTH + ? { requireCachedCredentials: true } + : undefined, + { + reason: 'user_request_acp', + context: 'session/set_model', + }, + ); // Get updated model info const currentModel = this.config.getModel(); + const currentAuthType = this.config.getAuthType?.() ?? selectedAuthType; return { - modelId: currentModel, + modelId: formatAcpModelId(currentModel, currentAuthType), }; } diff --git a/packages/cli/src/ui/components/ModelDialog.test.tsx b/packages/cli/src/ui/components/ModelDialog.test.tsx index 98da6031f..cd1476556 100644 --- a/packages/cli/src/ui/components/ModelDialog.test.tsx +++ b/packages/cli/src/ui/components/ModelDialog.test.tsx @@ -47,30 +47,36 @@ const renderComponent = ( setValue: vi.fn(), } as unknown as LoadedSettings; - const mockConfig = contextValue - ? ({ - // --- Functions used by ModelDialog --- - getModel: vi.fn(() => MAINLINE_CODER), - setModel: vi.fn().mockResolvedValue(undefined), - switchModel: vi.fn().mockResolvedValue(undefined), - getAuthType: vi.fn(() => 'qwen-oauth'), + const mockConfig = { + // --- Functions used by ModelDialog --- + getModel: vi.fn(() => MAINLINE_CODER), + setModel: vi.fn().mockResolvedValue(undefined), + switchModel: vi.fn().mockResolvedValue(undefined), + getAuthType: vi.fn(() => 'qwen-oauth'), + getAllConfiguredModels: vi.fn(() => + AVAILABLE_MODELS_QWEN.map((m) => ({ + id: m.id, + label: m.label, + description: m.description || '', + authType: AuthType.QWEN_OAUTH, + })), + ), - // --- Functions used by ClearcutLogger --- - getUsageStatisticsEnabled: vi.fn(() => true), - getSessionId: vi.fn(() => 'mock-session-id'), - getDebugMode: vi.fn(() => false), - getContentGeneratorConfig: vi.fn(() => ({ - authType: AuthType.QWEN_OAUTH, - model: MAINLINE_CODER, - })), - getUseSmartEdit: vi.fn(() => false), - getUseModelRouter: vi.fn(() => false), - getProxy: vi.fn(() => undefined), + // --- Functions used by ClearcutLogger --- + getUsageStatisticsEnabled: vi.fn(() => true), + getSessionId: vi.fn(() => 'mock-session-id'), + getDebugMode: vi.fn(() => false), + getContentGeneratorConfig: vi.fn(() => ({ + authType: AuthType.QWEN_OAUTH, + model: MAINLINE_CODER, + })), + getUseSmartEdit: vi.fn(() => false), + getUseModelRouter: vi.fn(() => false), + getProxy: vi.fn(() => undefined), - // --- Spread test-specific overrides --- - ...contextValue, - } as unknown as Config) - : undefined; + // --- Spread test-specific overrides --- + ...(contextValue ?? {}), + } as unknown as Config; const renderResult = render( @@ -308,6 +314,14 @@ describe('', () => { { getModel: mockGetModel, getAuthType: mockGetAuthType, + getAllConfiguredModels: vi.fn(() => + AVAILABLE_MODELS_QWEN.map((m) => ({ + id: m.id, + label: m.label, + description: m.description || '', + authType: AuthType.QWEN_OAUTH, + })), + ), } as unknown as Config } > @@ -322,6 +336,14 @@ describe('', () => { const newMockConfig = { getModel: mockGetModel, getAuthType: mockGetAuthType, + getAllConfiguredModels: vi.fn(() => + AVAILABLE_MODELS_QWEN.map((m) => ({ + id: m.id, + label: m.label, + description: m.description || '', + authType: AuthType.QWEN_OAUTH, + })), + ), } as unknown as Config; rerender( diff --git a/packages/cli/src/ui/components/ModelDialog.tsx b/packages/cli/src/ui/components/ModelDialog.tsx index 06c002070..f57abbd84 100644 --- a/packages/cli/src/ui/components/ModelDialog.tsx +++ b/packages/cli/src/ui/components/ModelDialog.tsx @@ -11,6 +11,7 @@ import { AuthType, ModelSlashCommandEvent, logModelSlashCommand, + type AvailableModel as CoreAvailableModel, type ContentGeneratorConfig, type ContentGeneratorConfigSource, type ContentGeneratorConfigSources, @@ -21,10 +22,7 @@ import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSel import { ConfigContext } from '../contexts/ConfigContext.js'; import { UIStateContext } from '../contexts/UIStateContext.js'; import { useSettings } from '../contexts/SettingsContext.js'; -import { - getAvailableModelsForAuthType, - MAINLINE_CODER, -} from '../models/availableModels.js'; +import { MAINLINE_CODER } from '../models/availableModels.js'; import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js'; import { t } from '../../i18n/index.js'; @@ -154,13 +152,17 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element { const sources = readSourcesFromConfig(config); const availableModelEntries = useMemo(() => { - const allAuthTypes = Object.values(AuthType) as AuthType[]; - const modelsByAuthType = allAuthTypes - .map((t) => ({ - authType: t, - models: getAvailableModelsForAuthType(t, config ?? undefined), - })) - .filter((x) => x.models.length > 0); + const allModels = config ? config.getAllConfiguredModels() : []; + + // Group models by authType + const modelsByAuthTypeMap = new Map(); + for (const model of allModels) { + const authType = model.authType; + if (!modelsByAuthTypeMap.has(authType)) { + modelsByAuthTypeMap.set(authType, []); + } + modelsByAuthTypeMap.get(authType)!.push(model); + } // Fixed order: qwen-oauth first, then others in a stable order const authTypeOrder: AuthType[] = [ @@ -171,15 +173,14 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element { AuthType.USE_VERTEX_AI, ]; - // Filter to only include authTypes that have models - const availableAuthTypes = new Set(modelsByAuthType.map((x) => x.authType)); + // Filter to only include authTypes that have models and maintain order + const availableAuthTypes = new Set(modelsByAuthTypeMap.keys()); const orderedAuthTypes = authTypeOrder.filter((t) => availableAuthTypes.has(t), ); return orderedAuthTypes.flatMap((t) => { - const models = - modelsByAuthType.find((x) => x.authType === t)?.models ?? []; + const models = modelsByAuthTypeMap.get(t) ?? []; return models.map((m) => ({ authType: t, model: m })); }); }, [config]); diff --git a/packages/cli/src/utils/acpModelUtils.test.ts b/packages/cli/src/utils/acpModelUtils.test.ts new file mode 100644 index 000000000..a8adddfc8 --- /dev/null +++ b/packages/cli/src/utils/acpModelUtils.test.ts @@ -0,0 +1,42 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { AuthType } from '@qwen-code/qwen-code-core'; +import { + formatAcpModelId, + parseAcpBaseModelId, + parseAcpModelOption, +} from './acpModelUtils.js'; + +describe('acpModelUtils', () => { + it('formats modelId(authType)', () => { + expect(formatAcpModelId('qwen3', AuthType.QWEN_OAUTH)).toBe( + `qwen3(${AuthType.QWEN_OAUTH})`, + ); + }); + + it('extracts base model id when string ends with parentheses', () => { + expect(parseAcpBaseModelId(`qwen3(${AuthType.USE_OPENAI})`)).toBe('qwen3'); + }); + + it('does not strip when parentheses are not a trailing suffix', () => { + expect(parseAcpBaseModelId('qwen3(x) y')).toBe('qwen3(x) y'); + }); + + it('parses modelId and validates authType', () => { + expect(parseAcpModelOption(` qwen3(${AuthType.USE_OPENAI}) `)).toEqual({ + modelId: 'qwen3', + authType: AuthType.USE_OPENAI, + }); + }); + + it('returns trimmed input as modelId when authType is invalid', () => { + expect(parseAcpModelOption('qwen3(not-a-real-auth)')).toEqual({ + modelId: 'qwen3(not-a-real-auth)', + }); + }); +}); diff --git a/packages/cli/src/utils/acpModelUtils.ts b/packages/cli/src/utils/acpModelUtils.ts new file mode 100644 index 000000000..039ae758b --- /dev/null +++ b/packages/cli/src/utils/acpModelUtils.ts @@ -0,0 +1,55 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { AuthType } from '@qwen-code/qwen-code-core'; +import { z } from 'zod'; + +/** + * ACP model IDs are represented as `${modelId}(${authType})` in the ACP protocol. + */ +export function formatAcpModelId(modelId: string, authType: AuthType): string { + return `${modelId}(${authType})`; +} + +/** + * Extracts the base model id from an ACP model id string. + * + * If the string ends with `(...)`, the suffix is removed; otherwise returns the + * trimmed input as-is. + */ +export function parseAcpBaseModelId(value: string): string { + const trimmed = value.trim(); + const closeIdx = trimmed.lastIndexOf(')'); + const openIdx = trimmed.lastIndexOf('('); + if (openIdx >= 0 && closeIdx === trimmed.length - 1 && openIdx < closeIdx) { + return trimmed.slice(0, openIdx); + } + return trimmed; +} + +/** + * Parses an ACP model option string into `{ modelId, authType? }`. + * + * If the string ends with `(...)` and `...` is a valid `AuthType`, returns both; + * otherwise returns the trimmed input as `modelId` only. + */ +export function parseAcpModelOption(input: string): { + modelId: string; + authType?: AuthType; +} { + const trimmed = input.trim(); + const closeIdx = trimmed.lastIndexOf(')'); + const openIdx = trimmed.lastIndexOf('('); + if (openIdx >= 0 && closeIdx === trimmed.length - 1 && openIdx < closeIdx) { + const maybeModelId = trimmed.slice(0, openIdx); + const maybeAuthType = trimmed.slice(openIdx + 1, closeIdx); + const parsedAuthType = z.nativeEnum(AuthType).safeParse(maybeAuthType); + if (parsedAuthType.success) { + return { modelId: maybeModelId, authType: parsedAuthType.data }; + } + } + return { modelId: trimmed }; +} diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index af2d28555..d33d70ad2 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -965,6 +965,14 @@ export class Config { return this.modelsConfig.getAvailableModelsForAuthType(authType); } + /** + * Get all configured models across authTypes. + * Delegates to ModelsConfig. + */ + getAllConfiguredModels(authTypes?: AuthType[]): AvailableModel[] { + return this._modelsConfig.getAllConfiguredModels(authTypes); + } + /** * Switch authType+model via registry-backed selection. * This triggers a refresh of the ContentGenerator when required (always on authType changes). diff --git a/packages/core/src/models/modelsConfig.test.ts b/packages/core/src/models/modelsConfig.test.ts index 36e40cfe8..82fb7790e 100644 --- a/packages/core/src/models/modelsConfig.test.ts +++ b/packages/core/src/models/modelsConfig.test.ts @@ -679,8 +679,8 @@ describe('ModelsConfig', () => { expect(modelsConfig.getGenerationConfig().model).toBe('updated-model'); }); - describe('getAllAvailableModels', () => { - it('should return all models across all authTypes', () => { + describe('getAllConfiguredModels', () => { + it('should return all models across all authTypes and put qwen-oauth first', () => { const modelProvidersConfig: ModelProvidersConfig = { openai: [ { @@ -718,7 +718,23 @@ describe('ModelsConfig', () => { modelProvidersConfig, }); - const allModels = modelsConfig.getAllAvailableModels(); + const allModels = modelsConfig.getAllConfiguredModels(); + + // qwen-oauth models should be ordered first + const firstNonQwenIndex = allModels.findIndex( + (m) => m.authType !== AuthType.QWEN_OAUTH, + ); + expect(firstNonQwenIndex).toBeGreaterThan(0); + expect( + allModels + .slice(0, firstNonQwenIndex) + .every((m) => m.authType === AuthType.QWEN_OAUTH), + ).toBe(true); + expect( + allModels + .slice(firstNonQwenIndex) + .every((m) => m.authType !== AuthType.QWEN_OAUTH), + ).toBe(true); // Should include qwen-oauth models (hard-coded) const qwenModels = allModels.filter( @@ -752,7 +768,7 @@ describe('ModelsConfig', () => { it('should return empty array when no models are registered', () => { const modelsConfig = new ModelsConfig(); - const allModels = modelsConfig.getAllAvailableModels(); + const allModels = modelsConfig.getAllConfiguredModels(); // Should still include qwen-oauth models (hard-coded) expect(allModels.length).toBeGreaterThan(0); @@ -782,7 +798,7 @@ describe('ModelsConfig', () => { modelProvidersConfig, }); - const allModels = modelsConfig.getAllAvailableModels(); + const allModels = modelsConfig.getAllConfiguredModels(); const testModel = allModels.find((m) => m.id === 'test-model'); expect(testModel).toBeDefined(); @@ -793,5 +809,56 @@ describe('ModelsConfig', () => { expect(testModel?.isVision).toBe(true); expect(testModel?.capabilities?.vision).toBe(true); }); + + it('should support filtering by authTypes and still put qwen-oauth first when included', () => { + const modelProvidersConfig: ModelProvidersConfig = { + openai: [ + { + id: 'openai-model-1', + name: 'OpenAI Model 1', + baseUrl: 'https://api.openai.com/v1', + envKey: 'OPENAI_API_KEY', + }, + ], + anthropic: [ + { + id: 'anthropic-model-1', + name: 'Anthropic Model 1', + baseUrl: 'https://api.anthropic.com/v1', + envKey: 'ANTHROPIC_API_KEY', + }, + ], + }; + + const modelsConfig = new ModelsConfig({ + modelProvidersConfig, + }); + + // Filter: OpenAI only (should not include qwen-oauth) + const openaiOnly = modelsConfig.getAllConfiguredModels([ + AuthType.USE_OPENAI, + ]); + expect(openaiOnly.every((m) => m.authType === AuthType.USE_OPENAI)).toBe( + true, + ); + expect(openaiOnly.map((m) => m.id)).toContain('openai-model-1'); + + // Filter: include qwen-oauth but request it later -> still ordered first + const withQwen = modelsConfig.getAllConfiguredModels([ + AuthType.USE_OPENAI, + AuthType.QWEN_OAUTH, + AuthType.USE_ANTHROPIC, + ]); + expect(withQwen.length).toBeGreaterThan(0); + const firstNonQwenIndex = withQwen.findIndex( + (m) => m.authType !== AuthType.QWEN_OAUTH, + ); + expect(firstNonQwenIndex).toBeGreaterThan(0); + expect( + withQwen + .slice(0, firstNonQwenIndex) + .every((m) => m.authType === AuthType.QWEN_OAUTH), + ).toBe(true); + }); }); }); diff --git a/packages/core/src/models/modelsConfig.ts b/packages/core/src/models/modelsConfig.ts index 74f7d250c..0dc2bd336 100644 --- a/packages/core/src/models/modelsConfig.ts +++ b/packages/core/src/models/modelsConfig.ts @@ -205,13 +205,40 @@ export class ModelsConfig { } /** - * Get all available models across all authTypes + * Get all configured models across authTypes. + * + * Notes: + * - By default, returns models across all authTypes. + * - qwen-oauth models are always ordered first. */ - getAllAvailableModels(): AvailableModel[] { + getAllConfiguredModels(authTypes?: AuthType[]): AvailableModel[] { + const inputAuthTypes = + authTypes && authTypes.length > 0 ? authTypes : Object.values(AuthType); + + // De-duplicate while preserving the original order. + const seen = new Set(); + const uniqueAuthTypes: AuthType[] = []; + for (const authType of inputAuthTypes) { + if (!seen.has(authType)) { + seen.add(authType); + uniqueAuthTypes.push(authType); + } + } + + // Force qwen-oauth to the front (if requested / defaulted in). + const orderedAuthTypes: AuthType[] = []; + if (uniqueAuthTypes.includes(AuthType.QWEN_OAUTH)) { + orderedAuthTypes.push(AuthType.QWEN_OAUTH); + } + for (const authType of uniqueAuthTypes) { + if (authType !== AuthType.QWEN_OAUTH) { + orderedAuthTypes.push(authType); + } + } + const allModels: AvailableModel[] = []; - for (const authType of Object.values(AuthType)) { - const models = this.modelRegistry.getModelsForAuthType(authType); - allModels.push(...models); + for (const authType of orderedAuthTypes) { + allModels.push(...this.modelRegistry.getModelsForAuthType(authType)); } return allModels; } From 6fa11a7baeeb8381850f96f56420597086acc2b9 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Tue, 27 Jan 2026 10:08:49 +0800 Subject: [PATCH 23/49] fix: remove unshifted current modelId and add auth error response --- packages/cli/src/acp-integration/acp.ts | 4 +++ packages/cli/src/acp-integration/acpAgent.ts | 36 ++++++++----------- .../src/acp-integration/session/Session.ts | 4 ++- 3 files changed, 22 insertions(+), 22 deletions(-) diff --git a/packages/cli/src/acp-integration/acp.ts b/packages/cli/src/acp-integration/acp.ts index 9725cdb01..c3705a98c 100644 --- a/packages/cli/src/acp-integration/acp.ts +++ b/packages/cli/src/acp-integration/acp.ts @@ -286,6 +286,10 @@ class Connection { return RequestError.authRequired(details).toResult(); } + if (details?.includes('/auth')) { + return RequestError.authRequired(details).toResult(); + } + return RequestError.internalError(details).toResult(); } } diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 1c766e7a2..ac16921ea 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -380,10 +380,10 @@ class GeminiAgent { const allConfiguredModels = config.getAllConfiguredModels(); const baseCurrentModelId = parseAcpBaseModelId(rawCurrentModelId); - const currentModelId = - currentAuthType && baseCurrentModelId - ? formatAcpModelId(baseCurrentModelId, currentAuthType) - : baseCurrentModelId; + const currentModelId = this.formatCurrentModelId( + baseCurrentModelId, + currentAuthType, + ); const availableModels = allConfiguredModels; @@ -396,26 +396,20 @@ class GeminiAgent { }, })); - if ( - currentModelId && - !mappedAvailableModels.some((model) => model.modelId === currentModelId) - ) { - const currentContextWindowSize = - config.getContentGeneratorConfig()?.contextWindowSize ?? - tokenLimit(currentModelId); - mappedAvailableModels.unshift({ - modelId: currentModelId, - name: currentModelId, - description: null, - _meta: { - contextLimit: currentContextWindowSize, - }, - }); - } - return { currentModelId, availableModels: mappedAvailableModels, }; } + + private formatCurrentModelId( + baseModelId: string, + authType?: AuthType, + ): string { + if (!baseModelId) { + return baseModelId; + } + + return authType ? formatAcpModelId(baseModelId, authType) : baseModelId; + } } diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index a819ccd2a..7f16f8ecb 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -371,7 +371,9 @@ export class Session implements SessionContext { const selectedAuthType = parsed.authType ?? previousAuthType; if (!selectedAuthType) { - throw acp.RequestError.invalidParams('authType cannot be determined'); + throw acp.RequestError.invalidParams( + `authType cannot be determined for modelId "${parsed.modelId}"`, + ); } await this.config.switchModel( From 06b37bd6bfaffe2cbe778a653eb0508326588293 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Sun, 1 Feb 2026 19:47:15 +0800 Subject: [PATCH 24/49] fix(acp): add authMethods in set_model response errors --- integration-tests/acp-integration.test.ts | 40 +++++++++++- packages/cli/src/acp-integration/acp.ts | 28 ++++++-- packages/cli/src/acp-integration/acpAgent.ts | 64 ++++++++++++++----- .../cli/src/acp-integration/authMethods.ts | 47 ++++++++++++++ packages/cli/src/acp-integration/schema.ts | 3 + packages/core/src/qwen/qwenOAuth2.test.ts | 4 +- packages/core/src/qwen/qwenOAuth2.ts | 4 +- 7 files changed, 166 insertions(+), 24 deletions(-) create mode 100644 packages/cli/src/acp-integration/authMethods.ts diff --git a/integration-tests/acp-integration.test.ts b/integration-tests/acp-integration.test.ts index 7b989e4c7..61a5a487d 100644 --- a/integration-tests/acp-integration.test.ts +++ b/integration-tests/acp-integration.test.ts @@ -146,7 +146,9 @@ function setupAcpTest( clearTimeout(waiter.timeout); pending.delete(msg.id); if (msg.error) { - waiter.reject(new Error(msg.error.message ?? 'Unknown error')); + const error = new Error(msg.error.message ?? 'Unknown error'); + (error as Error & { response?: unknown }).response = msg.error; + waiter.reject(error); } else { waiter.resolve(msg.result); } @@ -417,6 +419,42 @@ function setupAcpTest( } }); + it('includes authMethods in error data when auth is required', async () => { + const rig = new TestRig(); + rig.setup('acp auth methods in error data'); + + const { sendRequest, cleanup, stderr } = setupAcpTest(rig); + + try { + await sendRequest('initialize', { + protocolVersion: 1, + clientCapabilities: { + fs: { readTextFile: true, writeTextFile: true }, + }, + }); + + await expect( + sendRequest('session/new', { + cwd: rig.testDir!, + mcpServers: [], + }), + ).rejects.toMatchObject({ + response: { + data: { + authMethods: expect.any(Array), + }, + }, + }); + } catch (e) { + if (stderr.length) { + console.error('Agent stderr:', stderr.join('')); + } + throw e; + } finally { + await cleanup(); + } + }); + it('receives available_commands_update with slash commands after session creation', async () => { const rig = new TestRig(); rig.setup('acp slash commands'); diff --git a/packages/cli/src/acp-integration/acp.ts b/packages/cli/src/acp-integration/acp.ts index c3705a98c..68a936c0e 100644 --- a/packages/cli/src/acp-integration/acp.ts +++ b/packages/cli/src/acp-integration/acp.ts @@ -9,6 +9,7 @@ import { z } from 'zod'; import * as schema from './schema.js'; import { ACP_ERROR_CODES } from './errorCodes.js'; +import { pickAuthMethodsForDetails } from './authMethods.js'; export * from './schema.js'; import type { WritableStream, ReadableStream } from 'node:stream/web'; @@ -180,6 +181,7 @@ type ErrorResponse = { code: number; message: string; data?: unknown; + authMethods?: schema.AuthMethod[]; }; type PendingResponse = { @@ -282,8 +284,11 @@ class Connection { details = error.message; } - if (errorName === 'TokenManagerError') { - return RequestError.authRequired(details).toResult(); + if (errorName === 'TokenManagerError' || details?.includes('/auth')) { + return RequestError.authRequired( + details, + pickAuthMethodsForDetails(details), + ).toResult(); } if (details?.includes('/auth')) { @@ -339,17 +344,24 @@ class Connection { } export class RequestError extends Error { - data?: { details?: string }; + data?: { details?: string; authMethods?: schema.AuthMethod[] }; constructor( public code: number, message: string, details?: string, + authMethods?: schema.AuthMethod[], ) { super(message); this.name = 'RequestError'; - if (details) { - this.data = { details }; + if (details || authMethods) { + this.data = {}; + if (details) { + this.data.details = details; + } + if (authMethods) { + this.data.authMethods = authMethods; + } } } @@ -393,11 +405,15 @@ export class RequestError extends Error { ); } - static authRequired(details?: string): RequestError { + static authRequired( + details?: string, + authMethods?: schema.AuthMethod[], + ): RequestError { return new RequestError( ACP_ERROR_CODES.AUTH_REQUIRED, 'Authentication required', details, + authMethods, ); } diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index ac16921ea..9a2d2555e 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -22,6 +22,7 @@ import { } from '@qwen-code/qwen-code-core'; import type { ApprovalModeValue } from './schema.js'; import * as acp from './acp.js'; +import { buildAuthMethods } from './authMethods.js'; import { AcpFileSystemService } from './service/filesystem.js'; import { Readable, Writable } from 'node:stream'; import type { LoadedSettings } from '../config/settings.js'; @@ -73,20 +74,7 @@ class GeminiAgent { args: acp.InitializeRequest, ): Promise { this.clientCapabilities = args.clientCapabilities; - const authMethods = [ - { - id: AuthType.USE_OPENAI, - name: 'Use OpenAI API key', - description: - 'Requires setting the `OPENAI_API_KEY` environment variable', - }, - { - id: AuthType.QWEN_OAUTH, - name: 'Qwen OAuth', - description: - 'OAuth authentication for Qwen models with 2000 daily requests', - }, - ]; + const authMethods = buildAuthMethods(); // Get current approval mode from config const currentApprovalMode = this.config.getApprovalMode(); @@ -290,7 +278,7 @@ class GeminiAgent { `Session not found for id: ${params.sessionId}`, ); } - return session.setModel(params); + return await session.setModel(params); } private async ensureAuthenticated(config: Config): Promise { @@ -298,6 +286,7 @@ class GeminiAgent { if (!selectedType) { throw acp.RequestError.authRequired( 'Use Qwen Code CLI to authenticate first.', + this.pickAuthMethodsForAuthRequired(), ); } @@ -308,10 +297,55 @@ class GeminiAgent { console.error(`Authentication failed: ${e}`); throw acp.RequestError.authRequired( 'Authentication failed: ' + (e as Error).message, + this.pickAuthMethodsForAuthRequired(selectedType, e), ); } } + private pickAuthMethodsForAuthRequired( + selectedType?: AuthType | string, + error?: unknown, + ): acp.AuthMethod[] { + const authMethods = buildAuthMethods(); + const errorMessage = this.extractErrorMessage(error); + if ( + errorMessage?.includes('qwen-oauth') || + errorMessage?.includes('Qwen OAuth') + ) { + const qwenOAuthMethods = authMethods.filter( + (method) => method.id === AuthType.QWEN_OAUTH, + ); + return qwenOAuthMethods.length ? qwenOAuthMethods : authMethods; + } + + if (selectedType) { + const matchedMethods = authMethods.filter( + (method) => method.id === selectedType, + ); + return matchedMethods.length ? matchedMethods : authMethods; + } + + return authMethods; + } + + private extractErrorMessage(error?: unknown): string | undefined { + if (error instanceof Error) { + return error.message; + } + if ( + typeof error === 'object' && + error != null && + 'message' in error && + typeof error.message === 'string' + ) { + return error.message; + } + if (typeof error === 'string') { + return error; + } + return undefined; + } + private setupFileSystem(config: Config): void { if (!this.clientCapabilities?.fs) { return; diff --git a/packages/cli/src/acp-integration/authMethods.ts b/packages/cli/src/acp-integration/authMethods.ts new file mode 100644 index 000000000..35cafdc71 --- /dev/null +++ b/packages/cli/src/acp-integration/authMethods.ts @@ -0,0 +1,47 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { AuthType } from '@qwen-code/qwen-code-core'; +import type { AuthMethod } from './schema.js'; + +export function buildAuthMethods(): AuthMethod[] { + return [ + { + id: AuthType.USE_OPENAI, + name: 'Use OpenAI API key', + description: 'Requires setting the `OPENAI_API_KEY` environment variable', + type: 'terminal', + args: ['--auth-type=openai'], + }, + { + id: AuthType.QWEN_OAUTH, + name: 'Qwen OAuth', + description: + 'OAuth authentication for Qwen models with free daily requests', + type: 'terminal', + args: ['--auth-type=qwen-oauth'], + }, + ]; +} + +export function filterAuthMethodsById( + authMethods: AuthMethod[], + authMethodId: string, +): AuthMethod[] { + return authMethods.filter((method) => method.id === authMethodId); +} + +export function pickAuthMethodsForDetails(details?: string): AuthMethod[] { + const authMethods = buildAuthMethods(); + if (!details) { + return authMethods; + } + if (details.includes('qwen-oauth') || details.includes('Qwen OAuth')) { + const narrowed = filterAuthMethodsById(authMethods, AuthType.QWEN_OAUTH); + return narrowed.length ? narrowed : authMethods; + } + return authMethods; +} diff --git a/packages/cli/src/acp-integration/schema.ts b/packages/cli/src/acp-integration/schema.ts index 8e81b140d..cf616e56f 100644 --- a/packages/cli/src/acp-integration/schema.ts +++ b/packages/cli/src/acp-integration/schema.ts @@ -406,9 +406,12 @@ export const agentCapabilitiesSchema = z.object({ }); export const authMethodSchema = z.object({ + args: z.array(z.string()).optional(), description: z.string().nullable(), + env: z.record(z.string()).optional(), id: z.string(), name: z.string(), + type: z.string().optional(), }); export const clientResponseSchema = z.union([ diff --git a/packages/core/src/qwen/qwenOAuth2.test.ts b/packages/core/src/qwen/qwenOAuth2.test.ts index 920ca85e3..0d51f047e 100644 --- a/packages/core/src/qwen/qwenOAuth2.test.ts +++ b/packages/core/src/qwen/qwenOAuth2.test.ts @@ -840,7 +840,9 @@ describe('getQwenOAuthClient', () => { requireCachedCredentials: true, }), ), - ).rejects.toThrow('Please use /auth to re-authenticate.'); + ).rejects.toThrow( + 'Qwen OAuth credentials expired. Please use /auth to re-authenticate with qwen-oauth.', + ); expect(global.fetch).not.toHaveBeenCalled(); diff --git a/packages/core/src/qwen/qwenOAuth2.ts b/packages/core/src/qwen/qwenOAuth2.ts index b18c0319d..940bdcb18 100644 --- a/packages/core/src/qwen/qwenOAuth2.ts +++ b/packages/core/src/qwen/qwenOAuth2.ts @@ -516,7 +516,9 @@ export async function getQwenOAuthClient( } if (options?.requireCachedCredentials) { - throw new Error('Please use /auth to re-authenticate.'); + throw new Error( + 'Qwen OAuth credentials expired. Please use /auth to re-authenticate with qwen-oauth.', + ); } // If we couldn't obtain valid credentials via SharedTokenManager, fall back to From 0137b316d02caaaced567e9c33b47f47fefc3726 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Mon, 2 Feb 2026 15:57:43 +0800 Subject: [PATCH 25/49] feat: support runtime model selection for both interactive and ACP --- packages/cli/src/acp-integration/acp.ts | 4 - packages/cli/src/acp-integration/acpAgent.ts | 44 +- .../acp-integration/session/Session.test.ts | 4 - .../src/acp-integration/session/Session.ts | 4 - .../src/ui/components/ModelDialog.test.tsx | 8 - .../cli/src/ui/components/ModelDialog.tsx | 264 ++++++--- packages/cli/src/utils/acpModelUtils.ts | 6 + packages/core/src/config/config.ts | 25 +- packages/core/src/models/index.ts | 1 + packages/core/src/models/modelsConfig.test.ts | 401 ++++++++++++++ packages/core/src/models/modelsConfig.ts | 505 ++++++++++++++++-- packages/core/src/models/types.ts | 39 ++ 12 files changed, 1145 insertions(+), 160 deletions(-) diff --git a/packages/cli/src/acp-integration/acp.ts b/packages/cli/src/acp-integration/acp.ts index 68a936c0e..2a3bd222c 100644 --- a/packages/cli/src/acp-integration/acp.ts +++ b/packages/cli/src/acp-integration/acp.ts @@ -291,10 +291,6 @@ class Connection { ).toResult(); } - if (details?.includes('/auth')) { - return RequestError.authRequired(details).toResult(); - } - return RequestError.internalError(details).toResult(); } } diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 9a2d2555e..1e310356f 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -33,10 +33,7 @@ import { loadCliConfig } from '../config/config.js'; // Import the modular Session class import { Session } from './session/Session.js'; -import { - formatAcpModelId, - parseAcpBaseModelId, -} from '../utils/acpModelUtils.js'; +import { formatAcpModelId } from '../utils/acpModelUtils.js'; export async function runAcpAgent( config: Config, @@ -413,22 +410,35 @@ class GeminiAgent { const currentAuthType = config.getAuthType(); const allConfiguredModels = config.getAllConfiguredModels(); - const baseCurrentModelId = parseAcpBaseModelId(rawCurrentModelId); - const currentModelId = this.formatCurrentModelId( - baseCurrentModelId, - currentAuthType, - ); + // Check if current model is a runtime model + // Runtime models use $runtime|${authType}|${modelId} format + const activeRuntimeSnapshot = config.getActiveRuntimeModelSnapshot?.(); + const currentModelId = activeRuntimeSnapshot + ? formatAcpModelId( + activeRuntimeSnapshot.id, + activeRuntimeSnapshot.authType, + ) + : this.formatCurrentModelId(rawCurrentModelId, currentAuthType); const availableModels = allConfiguredModels; - const mappedAvailableModels = availableModels.map((model) => ({ - modelId: formatAcpModelId(model.id, model.authType), - name: model.label, - description: model.description ?? null, - _meta: { - contextLimit: model.contextWindowSize ?? tokenLimit(model.id), - }, - })); + const mappedAvailableModels = availableModels.map((model) => { + // For runtime models, use runtimeSnapshotId as modelId for ACP protocol + // This allows ACP clients to correctly identify and switch to runtime models + const effectiveModelId = + model.isRuntimeModel && model.runtimeSnapshotId + ? model.runtimeSnapshotId + : model.id; + + return { + modelId: formatAcpModelId(effectiveModelId, model.authType), + name: model.label, + description: model.description ?? null, + _meta: { + contextLimit: model.contextWindowSize ?? tokenLimit(model.id), + }, + }; + }); return { currentModelId, diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index 401d459cf..e33101df5 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -124,10 +124,6 @@ describe('Session', () => { AuthType.USE_OPENAI, 'qwen3-coder-plus', undefined, - { - reason: 'user_request_acp', - context: 'session/set_model', - }, ); expect(mockConfig.getModel).toHaveBeenCalled(); expect(result).toEqual({ diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 7f16f8ecb..bd596b878 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -383,10 +383,6 @@ export class Session implements SessionContext { selectedAuthType === AuthType.QWEN_OAUTH ? { requireCachedCredentials: true } : undefined, - { - reason: 'user_request_acp', - context: 'session/set_model', - }, ); // Get updated model info diff --git a/packages/cli/src/ui/components/ModelDialog.test.tsx b/packages/cli/src/ui/components/ModelDialog.test.tsx index cd1476556..1306456c9 100644 --- a/packages/cli/src/ui/components/ModelDialog.test.tsx +++ b/packages/cli/src/ui/components/ModelDialog.test.tsx @@ -182,10 +182,6 @@ describe('', () => { AuthType.QWEN_OAUTH, MAINLINE_CODER, undefined, - { - reason: 'user_manual', - context: 'Model switched via /model dialog', - }, ); expect(mockSettings.setValue).toHaveBeenCalledWith( SettingScope.User, @@ -242,10 +238,6 @@ describe('', () => { AuthType.QWEN_OAUTH, MAINLINE_CODER, { requireCachedCredentials: true }, - { - reason: 'user_manual', - context: 'AuthType+model switched via /model dialog', - }, ); expect(mockSettings.setValue).toHaveBeenCalledWith( SettingScope.User, diff --git a/packages/cli/src/ui/components/ModelDialog.tsx b/packages/cli/src/ui/components/ModelDialog.tsx index f57abbd84..8c102890f 100644 --- a/packages/cli/src/ui/components/ModelDialog.tsx +++ b/packages/cli/src/ui/components/ModelDialog.tsx @@ -20,7 +20,7 @@ import { useKeypress } from '../hooks/useKeypress.js'; import { theme } from '../semantic-colors.js'; import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSelect.js'; import { ConfigContext } from '../contexts/ConfigContext.js'; -import { UIStateContext } from '../contexts/UIStateContext.js'; +import { UIStateContext, type UIState } from '../contexts/UIStateContext.js'; import { useSettings } from '../contexts/SettingsContext.js'; import { MAINLINE_CODER } from '../models/availableModels.js'; import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js'; @@ -103,6 +103,46 @@ function persistAuthTypeSelection( settings.setValue(scope, 'security.auth.selectedType', authType); } +interface HandleModelSwitchSuccessParams { + settings: ReturnType; + uiState: UIState | null; + after: ContentGeneratorConfig | undefined; + effectiveAuthType: AuthType | undefined; + effectiveModelId: string; + isRuntime: boolean; +} + +function handleModelSwitchSuccess({ + settings, + uiState, + after, + effectiveAuthType, + effectiveModelId, + isRuntime, +}: HandleModelSwitchSuccessParams): void { + persistModelSelection(settings, effectiveModelId); + if (effectiveAuthType) { + persistAuthTypeSelection(settings, effectiveAuthType); + } + + const baseUrl = after?.baseUrl ?? t('(default)'); + const maskedKey = maskApiKey(after?.apiKey); + uiState?.historyManager.addItem( + { + type: 'info', + text: + `authType: ${effectiveAuthType ?? '(none)'}` + + `\n` + + `Using ${isRuntime ? 'runtime ' : ''}model: ${effectiveModelId}` + + `\n` + + `Base URL: ${baseUrl}` + + `\n` + + `API key: ${maskedKey}`, + }, + Date.now(), + ); +} + function ConfigRow({ label, value, @@ -154,9 +194,13 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element { const availableModelEntries = useMemo(() => { const allModels = config ? config.getAllConfiguredModels() : []; - // Group models by authType + // Separate runtime models from registry models + const runtimeModels = allModels.filter((m) => m.isRuntimeModel); + const registryModels = allModels.filter((m) => !m.isRuntimeModel); + + // Group registry models by authType const modelsByAuthTypeMap = new Map(); - for (const model of allModels) { + for (const model of registryModels) { const authType = model.authType; if (!modelsByAuthTypeMap.has(authType)) { modelsByAuthTypeMap.set(authType, []); @@ -173,43 +217,91 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element { AuthType.USE_VERTEX_AI, ]; - // Filter to only include authTypes that have models and maintain order + // Filter to only include authTypes that have registry models and maintain order const availableAuthTypes = new Set(modelsByAuthTypeMap.keys()); const orderedAuthTypes = authTypeOrder.filter((t) => availableAuthTypes.has(t), ); - return orderedAuthTypes.flatMap((t) => { - const models = modelsByAuthTypeMap.get(t) ?? []; - return models.map((m) => ({ authType: t, model: m })); - }); + // Build ordered list: runtime models first, then registry models grouped by authType + const result: Array<{ + authType: AuthType; + model: CoreAvailableModel; + isRuntime?: boolean; + snapshotId?: string; + }> = []; + + // Add all runtime models first + for (const runtimeModel of runtimeModels) { + result.push({ + authType: runtimeModel.authType, + model: runtimeModel, + isRuntime: true, + snapshotId: runtimeModel.runtimeSnapshotId, + }); + } + + // Add registry models grouped by authType + for (const t of orderedAuthTypes) { + for (const model of modelsByAuthTypeMap.get(t) ?? []) { + result.push({ authType: t, model, isRuntime: false }); + } + } + + return result; }, [config]); const MODEL_OPTIONS = useMemo( () => - availableModelEntries.map(({ authType: t2, model }) => { - const value = `${t2}::${model.id}`; - const title = ( - - - [{t2}] + availableModelEntries.map( + ({ authType: t2, model, isRuntime, snapshotId }) => { + // Runtime models use snapshotId directly (format: $runtime|${authType}|${modelId}) + const value = + isRuntime && snapshotId ? snapshotId : `${t2}::${model.id}`; + + const title = ( + + + [{t2}] + + {` ${model.label}`} + {isRuntime && ( + (Runtime) + )} - {` ${model.label}`} - - ); - const description = model.description || ''; - return { - value, - title, - description, - key: value, - }; - }), + ); + + // Include runtime indicator in description + let description = model.description || ''; + if (isRuntime) { + description = description + ? `${description} (Runtime)` + : 'Runtime model'; + } + + return { + value, + title, + description, + key: value, + }; + }, + ), [availableModelEntries], ); const preferredModelId = config?.getModel() || MAINLINE_CODER; - const preferredKey = authType ? `${authType}::${preferredModelId}` : ''; + // Check if current model is a runtime model + // Runtime snapshot ID is already in $runtime|${authType}|${modelId} format + const activeRuntimeSnapshot = config?.getActiveRuntimeModelSnapshot?.(); + const preferredKey = activeRuntimeSnapshot + ? activeRuntimeSnapshot.id + : authType + ? `${authType}::${preferredModelId}` + : ''; useKeypress( (key) => { @@ -229,67 +321,81 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element { const handleSelect = useCallback( async (selected: string) => { - // Clear any previous error setErrorMessage(null); - const sep = '::'; - const idx = selected.indexOf(sep); - const selectedAuthType = ( - idx >= 0 ? selected.slice(0, idx) : authType - ) as AuthType; - const modelId = idx >= 0 ? selected.slice(idx + sep.length) : selected; + let after: ContentGeneratorConfig | undefined; + let effectiveAuthType: AuthType | undefined; + let effectiveModelId = selected; + let isRuntime = false; - if (config) { - try { - await config.switchModel( - selectedAuthType, - modelId, - selectedAuthType !== authType && - selectedAuthType === AuthType.QWEN_OAUTH - ? { requireCachedCredentials: true } - : undefined, - { - reason: 'user_manual', - context: - selectedAuthType === authType - ? 'Model switched via /model dialog' - : 'AuthType+model switched via /model dialog', - }, - ); - } catch (e) { - const baseErrorMessage = e instanceof Error ? e.message : String(e); - setErrorMessage( - `Failed to switch model to '${modelId}'.\n\n${baseErrorMessage}`, - ); - return; + if (!config) { + onClose(); + return; + } + + try { + // Determine if this is a runtime model selection + // Runtime model format: $runtime|${authType}|${modelId} + isRuntime = selected.startsWith('$runtime|'); + + let selectedAuthType: AuthType; + let modelId: string; + + if (isRuntime) { + // For runtime models, extract authType from the snapshot ID + // Format: $runtime|${authType}|${modelId} + const parts = selected.split('|'); + if (parts.length >= 2 && parts[0] === '$runtime') { + selectedAuthType = parts[1] as AuthType; + } else { + selectedAuthType = authType as AuthType; + } + modelId = selected; // Pass the full snapshot ID to switchModel + } else { + const sep = '::'; + const idx = selected.indexOf(sep); + selectedAuthType = ( + idx >= 0 ? selected.slice(0, idx) : authType + ) as AuthType; + modelId = idx >= 0 ? selected.slice(idx + sep.length) : selected; } - const event = new ModelSlashCommandEvent(modelId); - logModelSlashCommand(config, event); - const after = config.getContentGeneratorConfig?.() as + await config.switchModel( + selectedAuthType, + modelId, + selectedAuthType !== authType && + selectedAuthType === AuthType.QWEN_OAUTH + ? { requireCachedCredentials: true } + : undefined, + ); + + if (!isRuntime) { + const event = new ModelSlashCommandEvent(modelId); + logModelSlashCommand(config, event); + } + + after = config.getContentGeneratorConfig?.() as | ContentGeneratorConfig | undefined; - const effectiveAuthType = - after?.authType ?? selectedAuthType ?? authType; - const effectiveModelId = after?.model ?? modelId; - - persistModelSelection(settings, effectiveModelId); - persistAuthTypeSelection(settings, effectiveAuthType); - - const baseUrl = after?.baseUrl ?? t('(default)'); - const maskedKey = maskApiKey(after?.apiKey); - uiState?.historyManager.addItem( - { - type: 'info', - text: - `authType: ${effectiveAuthType}\n` + - `Using model: ${effectiveModelId}\n` + - `Base URL: ${baseUrl}\n` + - `API key: ${maskedKey}`, - }, - Date.now(), - ); + effectiveAuthType = after?.authType ?? selectedAuthType ?? authType; + effectiveModelId = after?.model ?? modelId; + } catch (e) { + const baseErrorMessage = e instanceof Error ? e.message : String(e); + const errorPrefix = isRuntime + ? 'Failed to switch to runtime model.' + : `Failed to switch model to '${effectiveModelId ?? selected}'.`; + setErrorMessage(`${errorPrefix}\n\n${baseErrorMessage}`); + return; } + + handleModelSwitchSuccess({ + settings, + uiState, + after, + effectiveAuthType, + effectiveModelId, + isRuntime, + }); onClose(); }, [authType, config, onClose, settings, uiState, setErrorMessage], diff --git a/packages/cli/src/utils/acpModelUtils.ts b/packages/cli/src/utils/acpModelUtils.ts index 039ae758b..1def62533 100644 --- a/packages/cli/src/utils/acpModelUtils.ts +++ b/packages/cli/src/utils/acpModelUtils.ts @@ -33,6 +33,12 @@ export function parseAcpBaseModelId(value: string): string { /** * Parses an ACP model option string into `{ modelId, authType? }`. * + * Supports the following formats: + * - `${modelId}(${authType})` - Standard registry model (e.g., "gpt-4(USE_OPENAI)") + * - `${snapshotId}(${authType})` - Runtime model snapshot (e.g., "$runtime|USE_OPENAI|gpt-4(USE_OPENAI)") + * where snapshotId is in format `$runtime|${authType}|${modelId}` + * - Plain model ID - Returns as-is with no authType + * * If the string ends with `(...)` and `...` is a valid `AuthType`, returns both; * otherwise returns the trimmed input as `modelId` only. */ diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index d33d70ad2..761238c7e 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -112,6 +112,7 @@ import { ModelsConfig, type ModelProvidersConfig, type AvailableModel, + type RuntimeModelSnapshot, } from '../models/index.js'; import type { ClaudeMarketplaceConfig } from '../extension/claude-converter.js'; @@ -708,6 +709,9 @@ export class Config { await this.geminiClient.initialize(); + // Detect and capture runtime model snapshot (from CLI/ENV/credentials) + this.modelsConfig.detectAndCaptureRuntimeModel(); + logStartSession(this, new StartSessionEvent(this)); } @@ -970,26 +974,35 @@ export class Config { * Delegates to ModelsConfig. */ getAllConfiguredModels(authTypes?: AuthType[]): AvailableModel[] { - return this._modelsConfig.getAllConfiguredModels(authTypes); + return this.modelsConfig.getAllConfiguredModels(authTypes); } /** - * Switch authType+model via registry-backed selection. + * Get the currently active runtime model snapshot. + * Delegates to ModelsConfig. + */ + getActiveRuntimeModelSnapshot(): RuntimeModelSnapshot | undefined { + return this.modelsConfig.getActiveRuntimeModelSnapshot(); + } + + /** + * Switch authType+model. + * Supports both registry-backed models and runtime model snapshots. + * + * For runtime models, the modelId should be in format `$runtime|${authType}|${modelId}`. * This triggers a refresh of the ContentGenerator when required (always on authType changes). * For qwen-oauth model switches that are hot-update safe, this may update in place. * * @param authType - Target authentication type - * @param modelId - Target model ID + * @param modelId - Target model ID (or `$runtime|${authType}|${modelId}` for runtime models) * @param options - Additional options like requireCachedCredentials - * @param metadata - Metadata for logging/tracking */ async switchModel( authType: AuthType, modelId: string, options?: { requireCachedCredentials?: boolean }, - metadata?: { reason?: string; context?: string }, ): Promise { - await this.modelsConfig.switchModel(authType, modelId, options, metadata); + await this.modelsConfig.switchModel(authType, modelId, options); } getMaxSessionTurns(): number { diff --git a/packages/core/src/models/index.ts b/packages/core/src/models/index.ts index 7525074a5..0a18d64e4 100644 --- a/packages/core/src/models/index.ts +++ b/packages/core/src/models/index.ts @@ -12,6 +12,7 @@ export { type ResolvedModelConfig, type AvailableModel, type ModelSwitchMetadata, + type RuntimeModelSnapshot, } from './types.js'; export { ModelRegistry } from './modelRegistry.js'; diff --git a/packages/core/src/models/modelsConfig.test.ts b/packages/core/src/models/modelsConfig.test.ts index 82fb7790e..701386ac8 100644 --- a/packages/core/src/models/modelsConfig.test.ts +++ b/packages/core/src/models/modelsConfig.test.ts @@ -861,4 +861,405 @@ describe('ModelsConfig', () => { ).toBe(true); }); }); + + describe('Runtime Model Snapshot', () => { + it('should detect and capture runtime model from CLI source', () => { + const modelsConfig = new ModelsConfig({ + initialAuthType: AuthType.USE_OPENAI, + generationConfig: { + model: 'gpt-4-turbo', + apiKey: 'sk-test-key', + baseUrl: 'https://api.openai.com/v1', + }, + generationConfigSources: { + model: { kind: 'cli', detail: '--model' }, + apiKey: { kind: 'cli', detail: '--openaiApiKey' }, + baseUrl: { kind: 'cli', detail: '--openaiBaseUrl' }, + }, + }); + + const snapshotId = modelsConfig.detectAndCaptureRuntimeModel(); + + expect(snapshotId).toBe('$runtime|openai|gpt-4-turbo'); + + const snapshot = modelsConfig.getActiveRuntimeModelSnapshot(); + expect(snapshot).toBeDefined(); + expect(snapshot?.id).toBe('$runtime|openai|gpt-4-turbo'); + expect(snapshot?.authType).toBe(AuthType.USE_OPENAI); + expect(snapshot?.modelId).toBe('gpt-4-turbo'); + expect(snapshot?.apiKey).toBe('sk-test-key'); + expect(snapshot?.baseUrl).toBe('https://api.openai.com/v1'); + }); + + it('should detect and capture runtime model from ENV source', () => { + const modelsConfig = new ModelsConfig({ + initialAuthType: AuthType.USE_OPENAI, + generationConfig: { + model: 'gpt-4o', + apiKey: 'sk-env-key', + baseUrl: 'https://api.openai.com/v1', + }, + generationConfigSources: { + model: { kind: 'settings', detail: 'settings.model.name' }, + apiKey: { kind: 'env', envKey: 'OPENAI_API_KEY' }, + baseUrl: { kind: 'settings', detail: 'settings.openaiBaseUrl' }, + }, + }); + + const snapshotId = modelsConfig.detectAndCaptureRuntimeModel(); + + expect(snapshotId).toBe('$runtime|openai|gpt-4o'); + + const snapshot = modelsConfig.getActiveRuntimeModelSnapshot(); + expect(snapshot).toBeDefined(); + expect(snapshot?.modelId).toBe('gpt-4o'); + expect(snapshot?.apiKey).toBe('sk-env-key'); + }); + + it('should not capture registry models as runtime', () => { + const modelProvidersConfig: ModelProvidersConfig = { + openai: [ + { + id: 'gpt-4-turbo', + name: 'GPT-4 Turbo', + baseUrl: 'https://api.openai.com/v1', + envKey: 'OPENAI_API_KEY', + }, + ], + }; + + const modelsConfig = new ModelsConfig({ + initialAuthType: AuthType.USE_OPENAI, + modelProvidersConfig, + generationConfig: { + model: 'gpt-4-turbo', + apiKey: 'sk-test-key', + baseUrl: 'https://api.openai.com/v1', + }, + generationConfigSources: { + model: { kind: 'cli', detail: '--model' }, + apiKey: { kind: 'cli', detail: '--openaiApiKey' }, + baseUrl: { kind: 'cli', detail: '--openaiBaseUrl' }, + }, + }); + + const snapshotId = modelsConfig.detectAndCaptureRuntimeModel(); + + // Should not create snapshot since model exists in registry + expect(snapshotId).toBeUndefined(); + expect(modelsConfig.getActiveRuntimeModelSnapshot()).toBeUndefined(); + }); + + it('should not capture runtime model without valid credentials', () => { + const modelsConfig = new ModelsConfig({ + initialAuthType: AuthType.USE_OPENAI, + generationConfig: { + model: 'custom-model', + // Missing apiKey and baseUrl + }, + generationConfigSources: { + model: { kind: 'cli', detail: '--model' }, + }, + }); + + const snapshotId = modelsConfig.detectAndCaptureRuntimeModel(); + + expect(snapshotId).toBeUndefined(); + }); + + it('should switch to runtime model and apply snapshot configuration', async () => { + const modelsConfig = new ModelsConfig({ + initialAuthType: AuthType.USE_OPENAI, + generationConfig: { + model: 'runtime-model', + apiKey: 'sk-runtime-key', + baseUrl: 'https://runtime.example.com/v1', + samplingParams: { temperature: 0.7, max_tokens: 2000 }, + }, + generationConfigSources: { + model: { kind: 'programmatic', detail: 'test' }, + apiKey: { kind: 'programmatic', detail: 'test' }, + baseUrl: { kind: 'programmatic', detail: 'test' }, + }, + }); + + // Create initial snapshot + const initialSnapshotId = modelsConfig.detectAndCaptureRuntimeModel(); + expect(initialSnapshotId).toBeDefined(); + + // Change to a different state + // Note: this updates the existing snapshot, changing its ID + modelsConfig.updateCredentials({ + model: 'different-model', + apiKey: 'different-key', + baseUrl: 'https://different.example.com/v1', + }); + + // The snapshot ID has changed because we updated the model + const updatedSnapshotId = modelsConfig.getActiveRuntimeModelSnapshotId(); + expect(updatedSnapshotId).toBe('$runtime|openai|different-model'); + + // Create a separate snapshot for the original runtime model + // (simulate having multiple runtime models available) + modelsConfig['runtimeModelSnapshots'].set( + '$runtime|openai|runtime-model', + { + id: '$runtime|openai|runtime-model', + authType: AuthType.USE_OPENAI, + modelId: 'runtime-model', + apiKey: 'sk-runtime-key', + baseUrl: 'https://runtime.example.com/v1', + generationConfig: { + samplingParams: { temperature: 0.7, max_tokens: 2000 }, + }, + sources: { + model: { kind: 'programmatic', detail: 'test' }, + apiKey: { kind: 'programmatic', detail: 'test' }, + baseUrl: { kind: 'programmatic', detail: 'test' }, + }, + createdAt: Date.now(), + }, + ); + + // Switch back to original runtime model + await modelsConfig.switchToRuntimeModel('$runtime|openai|runtime-model'); + + const gc = currentGenerationConfig(modelsConfig); + expect(gc.model).toBe('runtime-model'); + expect(gc.apiKey).toBe('sk-runtime-key'); + expect(gc.baseUrl).toBe('https://runtime.example.com/v1'); + expect(gc.samplingParams?.temperature).toBe(0.7); + expect(gc.samplingParams?.max_tokens).toBe(2000); + }); + + it('should throw error when switching to non-existent runtime snapshot', async () => { + const modelsConfig = new ModelsConfig({ + initialAuthType: AuthType.USE_OPENAI, + }); + + await expect( + modelsConfig.switchToRuntimeModel('$runtime|openai|nonexistent'), + ).rejects.toThrow( + "Runtime model snapshot '$runtime|openai|nonexistent' not found", + ); + }); + + it('should return runtime option first in getAllConfiguredModels', () => { + const modelProvidersConfig: ModelProvidersConfig = { + openai: [ + { + id: 'registry-model', + name: 'Registry Model', + baseUrl: 'https://api.openai.com/v1', + envKey: 'OPENAI_API_KEY', + }, + ], + }; + + const modelsConfig = new ModelsConfig({ + initialAuthType: AuthType.USE_OPENAI, + modelProvidersConfig, + generationConfig: { + model: 'runtime-model', + apiKey: 'sk-test-key', + baseUrl: 'https://runtime.example.com/v1', + }, + generationConfigSources: { + model: { kind: 'programmatic', detail: 'test' }, + apiKey: { kind: 'programmatic', detail: 'test' }, + baseUrl: { kind: 'programmatic', detail: 'test' }, + }, + }); + + modelsConfig.detectAndCaptureRuntimeModel(); + + const allModels = modelsConfig.getAllConfiguredModels(); + + // Runtime model should be first for USE_OPENAI + const openaiModels = allModels.filter( + (m) => m.authType === AuthType.USE_OPENAI, + ); + expect(openaiModels.length).toBe(2); + expect(openaiModels[0].isRuntimeModel).toBe(true); + // AvailableModel.id should be modelId, runtimeSnapshotId should be snapshot.id + expect(openaiModels[0].id).toBe('runtime-model'); + expect(openaiModels[0].runtimeSnapshotId).toBe( + '$runtime|openai|runtime-model', + ); + expect(openaiModels[0].label).toBe('runtime-model'); + expect(openaiModels[1].isRuntimeModel).toBeUndefined(); + expect(openaiModels[1].id).toBe('registry-model'); + }); + + it('should create/update runtime snapshot via updateCredentials', () => { + const modelsConfig = new ModelsConfig({ + initialAuthType: AuthType.USE_OPENAI, + }); + + // Update with complete credentials + modelsConfig.updateCredentials({ + model: 'custom-model', + apiKey: 'sk-custom-key', + baseUrl: 'https://custom.example.com/v1', + }); + + const snapshot = modelsConfig.getActiveRuntimeModelSnapshot(); + expect(snapshot).toBeDefined(); + expect(snapshot?.modelId).toBe('custom-model'); + expect(snapshot?.apiKey).toBe('sk-custom-key'); + expect(snapshot?.baseUrl).toBe('https://custom.example.com/v1'); + }); + + it('should update existing runtime snapshot when credentials change', () => { + const modelsConfig = new ModelsConfig({ + initialAuthType: AuthType.USE_OPENAI, + generationConfig: { + model: 'initial-model', + apiKey: 'sk-initial-key', + baseUrl: 'https://initial.example.com/v1', + }, + generationConfigSources: { + model: { kind: 'programmatic', detail: 'test' }, + apiKey: { kind: 'programmatic', detail: 'test' }, + baseUrl: { kind: 'programmatic', detail: 'test' }, + }, + }); + + // Create initial snapshot + modelsConfig.detectAndCaptureRuntimeModel(); + + // Update credentials with different model + modelsConfig.updateCredentials({ + model: 'updated-model', + apiKey: 'sk-updated-key', + }); + + const snapshot = modelsConfig.getActiveRuntimeModelSnapshot(); + expect(snapshot).toBeDefined(); + expect(snapshot?.modelId).toBe('updated-model'); + expect(snapshot?.apiKey).toBe('sk-updated-key'); + // baseUrl should be preserved from initial + expect(snapshot?.baseUrl).toBe('https://initial.example.com/v1'); + }); + + it('should enforce per-authType snapshot limit', () => { + const modelsConfig = new ModelsConfig({ + initialAuthType: AuthType.USE_OPENAI, + }); + + // Create first snapshot for USE_OPENAI + modelsConfig.updateCredentials({ + model: 'model-a', + apiKey: 'sk-key-a', + baseUrl: 'https://a.example.com/v1', + }); + + const firstSnapshotId = modelsConfig.getActiveRuntimeModelSnapshotId(); + expect(firstSnapshotId).toBe('$runtime|openai|model-a'); + + // Create second snapshot for USE_OPENAI (different model) + modelsConfig.updateCredentials({ + model: 'model-b', + apiKey: 'sk-key-b', + baseUrl: 'https://b.example.com/v1', + }); + + const secondSnapshotId = modelsConfig.getActiveRuntimeModelSnapshotId(); + expect(secondSnapshotId).toBe('$runtime|openai|model-b'); + + // First snapshot should be cleaned up + expect(modelsConfig.getActiveRuntimeModelSnapshot()?.id).toBe( + secondSnapshotId, + ); + }); + + it('should support multiple authTypes with separate snapshots', async () => { + const modelsConfig = new ModelsConfig({ + initialAuthType: AuthType.USE_OPENAI, + }); + + // Create OpenAI snapshot + modelsConfig.updateCredentials({ + model: 'openai-model', + apiKey: 'sk-openai-key', + baseUrl: 'https://openai.example.com/v1', + }); + + // Verify OpenAI snapshot exists + const openaiSnapshot = modelsConfig.getActiveRuntimeModelSnapshot(); + expect(openaiSnapshot?.authType).toBe(AuthType.USE_OPENAI); + expect(openaiSnapshot?.modelId).toBe('openai-model'); + + // Switch to Anthropic via switchToRuntimeModel + // First create an Anthropic snapshot manually + modelsConfig['runtimeModelSnapshots'].set( + '$runtime|anthropic|anthropic-model', + { + id: '$runtime|anthropic|anthropic-model', + authType: AuthType.USE_ANTHROPIC, + modelId: 'anthropic-model', + apiKey: 'sk-anthropic-key', + baseUrl: 'https://anthropic.example.com/v1', + sources: { + model: { kind: 'programmatic', detail: 'test' }, + apiKey: { kind: 'programmatic', detail: 'test' }, + baseUrl: { kind: 'programmatic', detail: 'test' }, + }, + createdAt: Date.now(), + }, + ); + + // Switch to the Anthropic runtime model + await modelsConfig.switchToRuntimeModel( + '$runtime|anthropic|anthropic-model', + ); + + // Should now have Anthropic snapshot active + const anthropicSnapshot = modelsConfig.getActiveRuntimeModelSnapshot(); + expect(anthropicSnapshot?.authType).toBe(AuthType.USE_ANTHROPIC); + expect(anthropicSnapshot?.modelId).toBe('anthropic-model'); + }); + + it('should rollback state when switchToRuntimeModel fails', async () => { + const modelsConfig = new ModelsConfig({ + initialAuthType: AuthType.USE_OPENAI, + generationConfig: { + model: 'runtime-model', + apiKey: 'sk-runtime-key', + baseUrl: 'https://runtime.example.com/v1', + }, + generationConfigSources: { + model: { kind: 'programmatic', detail: 'test' }, + apiKey: { kind: 'programmatic', detail: 'test' }, + baseUrl: { kind: 'programmatic', detail: 'test' }, + }, + }); + + // Create snapshot + const snapshotId = modelsConfig.detectAndCaptureRuntimeModel(); + expect(snapshotId).toBeDefined(); + + // Set up onModelChange to fail + modelsConfig.setOnModelChange(async () => { + throw new Error('refresh failed'); + }); + + // Store baseline state + const baselineModel = modelsConfig.getModel(); + const baselineGc = snapshotGenerationConfig(modelsConfig); + + // Try to switch - should fail + await expect( + modelsConfig.switchToRuntimeModel(snapshotId!), + ).rejects.toThrow('refresh failed'); + + // State should be rolled back + expect(modelsConfig.getModel()).toBe(baselineModel); + expect(modelsConfig.getGenerationConfig()).toMatchObject({ + model: baselineGc.model, + apiKey: baselineGc.apiKey, + baseUrl: baselineGc.baseUrl, + }); + }); + }); }); diff --git a/packages/core/src/models/modelsConfig.ts b/packages/core/src/models/modelsConfig.ts index 0dc2bd336..bc5f56796 100644 --- a/packages/core/src/models/modelsConfig.ts +++ b/packages/core/src/models/modelsConfig.ts @@ -18,6 +18,7 @@ import { type ResolvedModelConfig, type AvailableModel, type ModelSwitchMetadata, + type RuntimeModelSnapshot, } from './types.js'; import { MODEL_GENERATION_CONFIG_FIELDS, @@ -99,6 +100,31 @@ export class ModelsConfig { // Flag indicating whether authType was explicitly provided (not defaulted) private readonly authTypeWasExplicitlyProvided: boolean; + /** + * Runtime model snapshot storage. + * + * These snapshots store runtime-resolved model configurations that are NOT from + * modelProviders registry (e.g., models with manually set credentials). + * + * Key: snapshotId (format: `$runtime|${authType}|${modelId}`) + * Uses `$runtime|` prefix since `$` and `|` are unlikely to appear in real model IDs. + * This prevents conflicts with model IDs containing `-` or `:` characters. + * Value: RuntimeModelSnapshot containing the model's configuration + * + * Note: This is different from state snapshots used for rollback during model switching. + * RuntimeModelSnapshot stores persistent model configurations, while state snapshots + * are temporary and used only for error recovery. + */ + private runtimeModelSnapshots: Map = new Map(); + + /** + * Currently active RuntimeModelSnapshot ID. + * + * When set, indicates that the current model is a runtime model (not from registry). + * This ID is included in state snapshots for rollback purposes. + */ + private activeRuntimeModelSnapshotId: string | undefined; + private static deepClone(value: T): T { if (value === null || typeof value !== 'object') { return value; @@ -115,38 +141,6 @@ export class ModelsConfig { return out as T; } - private snapshotState(): { - currentAuthType: AuthType | undefined; - generationConfig: Partial; - generationConfigSources: ContentGeneratorConfigSources; - strictModelProviderSelection: boolean; - requireCachedQwenCredentialsOnce: boolean; - hasManualCredentials: boolean; - } { - return { - currentAuthType: this.currentAuthType, - generationConfig: ModelsConfig.deepClone(this._generationConfig), - generationConfigSources: ModelsConfig.deepClone( - this.generationConfigSources, - ), - strictModelProviderSelection: this.strictModelProviderSelection, - requireCachedQwenCredentialsOnce: this.requireCachedQwenCredentialsOnce, - hasManualCredentials: this.hasManualCredentials, - }; - } - - private restoreState( - snapshot: ReturnType, - ): void { - this.currentAuthType = snapshot.currentAuthType; - this._generationConfig = snapshot.generationConfig; - this.generationConfigSources = snapshot.generationConfigSources; - this.strictModelProviderSelection = snapshot.strictModelProviderSelection; - this.requireCachedQwenCredentialsOnce = - snapshot.requireCachedQwenCredentialsOnce; - this.hasManualCredentials = snapshot.hasManualCredentials; - } - constructor(options: ModelsConfigOptions = {}) { this.modelRegistry = new ModelRegistry(options.modelProvidersConfig); this.onModelChange = options.onModelChange; @@ -166,6 +160,53 @@ export class ModelsConfig { this.currentAuthType = options.initialAuthType; } + /** + * Create a snapshot of the current ModelsConfig state for rollback purposes. + * Used before model switching operations to enable recovery on errors. + * + * Note: This is different from RuntimeModelSnapshot which stores runtime model configs. + */ + private createStateSnapshotForRollback(): { + currentAuthType: AuthType | undefined; + generationConfig: Partial; + generationConfigSources: ContentGeneratorConfigSources; + strictModelProviderSelection: boolean; + requireCachedQwenCredentialsOnce: boolean; + hasManualCredentials: boolean; + activeRuntimeModelSnapshotId: string | undefined; + } { + return { + currentAuthType: this.currentAuthType, + generationConfig: ModelsConfig.deepClone(this._generationConfig), + generationConfigSources: ModelsConfig.deepClone( + this.generationConfigSources, + ), + strictModelProviderSelection: this.strictModelProviderSelection, + requireCachedQwenCredentialsOnce: this.requireCachedQwenCredentialsOnce, + hasManualCredentials: this.hasManualCredentials, + activeRuntimeModelSnapshotId: this.activeRuntimeModelSnapshotId, + }; + } + + /** + * Restore ModelsConfig state from a previously created state snapshot. + * Used for rollback when model switching operations fail. + * + * @param snapshot - The state snapshot to restore + */ + private rollbackToStateSnapshot( + snapshot: ReturnType, + ): void { + this.currentAuthType = snapshot.currentAuthType; + this._generationConfig = snapshot.generationConfig; + this.generationConfigSources = snapshot.generationConfigSources; + this.strictModelProviderSelection = snapshot.strictModelProviderSelection; + this.requireCachedQwenCredentialsOnce = + snapshot.requireCachedQwenCredentialsOnce; + this.hasManualCredentials = snapshot.hasManualCredentials; + this.activeRuntimeModelSnapshotId = snapshot.activeRuntimeModelSnapshotId; + } + /** * Get current model ID */ @@ -210,6 +251,7 @@ export class ModelsConfig { * Notes: * - By default, returns models across all authTypes. * - qwen-oauth models are always ordered first. + * - Runtime model option (if active) is included before registry models of the same authType. */ getAllConfiguredModels(authTypes?: AuthType[]): AvailableModel[] { const inputAuthTypes = @@ -236,8 +278,16 @@ export class ModelsConfig { } } + // Get runtime model option + const runtimeOption = this.getRuntimeModelOption(); + const allModels: AvailableModel[] = []; for (const authType of orderedAuthTypes) { + // Add runtime option first if it matches this authType + if (runtimeOption && runtimeOption.authType === authType) { + allModels.push(runtimeOption); + } + // Add registry models allModels.push(...this.modelRegistry.getModelsForAuthType(authType)); } return allModels; @@ -296,16 +346,29 @@ export class ModelsConfig { } /** - * Switch model (and optionally authType) via registry-backed selection. - * This is a superset of the previous split APIs for model-only vs authType+model switching. + * Switch model (and optionally authType). + * Supports both registry-backed models and RuntimeModelSnapshots. + * + * For runtime models, the modelId can be: + * - A RuntimeModelSnapshot ID (format: `$runtime|${authType}|${modelId}`) + * - With explicit `$runtime|` prefix (format: `$runtime|${authType}|${modelId}`) + * + * When called from ACP integration, the modelId has already been parsed + * by parseAcpModelOption, which strips any (${authType}) suffix. */ async switchModel( authType: AuthType, modelId: string, options?: { requireCachedCredentials?: boolean }, - _metadata?: ModelSwitchMetadata, ): Promise { - const snapshot = this.snapshotState(); + // Check if this is a RuntimeModelSnapshot reference + const runtimeModelSnapshotId = this.extractRuntimeModelSnapshotId(modelId); + if (runtimeModelSnapshotId) { + await this.switchToRuntimeModel(runtimeModelSnapshotId); + return; + } + + const rollbackSnapshot = this.createStateSnapshotForRollback(); if (authType === AuthType.QWEN_OAUTH && options?.requireCachedCredentials) { this.requireCachedQwenCredentialsOnce = true; } @@ -326,18 +389,77 @@ export class ModelsConfig { const requiresRefresh = isAuthTypeChange ? true - : this.checkRequiresRefresh(snapshot.generationConfig.model || ''); + : this.checkRequiresRefresh( + rollbackSnapshot.generationConfig.model || '', + ); if (this.onModelChange) { await this.onModelChange(authType, requiresRefresh); } } catch (error) { // Rollback on error - this.restoreState(snapshot); + this.rollbackToStateSnapshot(rollbackSnapshot); throw error; } } + /** + * Prefix used to identify RuntimeModelSnapshot IDs. + * Chosen to avoid conflicts with real model IDs which may contain `-` or `:`. + */ + private static readonly RUNTIME_SNAPSHOT_PREFIX = '$runtime|'; + + /** + * Build a RuntimeModelSnapshot ID from authType and modelId. + * The format is: `$runtime|${authType}|${modelId}` + * + * This is the canonical way to construct snapshot IDs, ensuring + * consistency across creation and lookup. + * + * @param authType - The authentication type + * @param modelId - The model ID + * @returns The snapshot ID in format `$runtime|${authType}|${modelId}` + */ + private buildRuntimeModelSnapshotId( + authType: AuthType, + modelId: string, + ): string { + return `${ModelsConfig.RUNTIME_SNAPSHOT_PREFIX}${authType}|${modelId}`; + } + + /** + * Extract RuntimeModelSnapshot ID from modelId if it's a runtime model reference. + * + * Supports the following formats: + * - Direct snapshot ID: `$runtime|${authType}|${modelId}` → returns as-is if exists in Map + * - Direct snapshot ID match: returns if exists in Map + * + * Note: When called from ACP integration via setModel, the modelId has already + * been parsed by parseAcpModelOption which strips any (${authType}) suffix. + * So we don't need to handle ACP format here - the ACP layer handles that. + * + * @param modelId - The model ID to parse + * @returns The RuntimeModelSnapshot ID if found, undefined otherwise + */ + private extractRuntimeModelSnapshotId(modelId: string): string | undefined { + // Check if modelId starts with the runtime snapshot prefix + if (modelId.startsWith(ModelsConfig.RUNTIME_SNAPSHOT_PREFIX)) { + // Verify the snapshot exists + if (this.runtimeModelSnapshots.has(modelId)) { + return modelId; + } + // Even with prefix, if it doesn't exist, don't return it + return undefined; + } + + // Check if modelId itself is a valid snapshot ID (exists in Map) + if (this.runtimeModelSnapshots.has(modelId)) { + return modelId; + } + + return undefined; + } + /** * Get generation config for ContentGenerator creation */ @@ -387,6 +509,9 @@ export class ModelsConfig { * to maintain provider atomicity (either fully applied or not at all). * Other layers (CLI, env, settings, defaults) will participate in resolve. * + * Also updates or creates a RuntimeModelSnapshot when credentials form a complete config + * for a model not in the registry. This allows the runtime model to be reused later. + * * @param settingsGenerationConfig Optional generation config from settings.json * to merge after clearing provider-sourced config. * This ensures settings.model.generationConfig fields @@ -447,6 +572,66 @@ export class ModelsConfig { if (settingsGenerationConfig) { this.mergeSettingsGenerationConfig(settingsGenerationConfig); } + + // Sync with runtime model snapshot if we have a complete configuration + this.syncRuntimeModelSnapshotWithCredentials(); + } + + /** + * Sync RuntimeModelSnapshot with current credentials. + * + * Creates or updates a RuntimeModelSnapshot when current credentials form a complete + * configuration for a model not in the registry. This enables: + * - Reusing the runtime model configuration later + * - Showing the runtime model as an available option in model lists + * + * Only creates snapshots for models NOT in the registry (to avoid duplication). + */ + private syncRuntimeModelSnapshotWithCredentials(): void { + const currentAuthType = this.currentAuthType; + const { model, apiKey, baseUrl } = this._generationConfig; + + // Early return if missing required fields + if (!model || !currentAuthType || !apiKey || !baseUrl) { + return; + } + + // Check if model exists in registry - if so, don't create RuntimeModelSnapshot + if (this.modelRegistry.hasModel(currentAuthType, model)) { + return; + } + + // If we have an active snapshot, update it + if ( + this.activeRuntimeModelSnapshotId && + this.runtimeModelSnapshots.has(this.activeRuntimeModelSnapshotId) + ) { + const snapshot = this.runtimeModelSnapshots.get( + this.activeRuntimeModelSnapshotId, + )!; + + // Update snapshot with current values (already verified to exist above) + snapshot.apiKey = apiKey; + snapshot.baseUrl = baseUrl; + snapshot.modelId = model; + + // Update ID if model changed + const newSnapshotId = this.buildRuntimeModelSnapshotId( + snapshot.authType, + snapshot.modelId, + ); + if (newSnapshotId !== snapshot.id) { + this.runtimeModelSnapshots.delete(snapshot.id); + snapshot.id = newSnapshotId; + this.runtimeModelSnapshots.set(newSnapshotId, snapshot); + this.activeRuntimeModelSnapshotId = newSnapshotId; + } + + snapshot.createdAt = Date.now(); + } else { + // Create new snapshot + this.detectAndCaptureRuntimeModel(); + } } /** @@ -784,4 +969,248 @@ export class ModelsConfig { setOnModelChange(callback: OnModelChangeCallback): void { this.onModelChange = callback; } + + /** + * Detect and capture RuntimeModelSnapshot during initialization. + * + * Checks if the current configuration represents a runtime model (not from + * modelProviders registry) and captures it as a RuntimeModelSnapshot. + * + * This enables runtime models to persist across sessions and appear in model lists. + * + * @returns Created snapshot ID, or undefined if current config is a registry model + */ + detectAndCaptureRuntimeModel(): string | undefined { + const { + model: currentModel, + apiKey, + baseUrl, + apiKeyEnvKey, + ...generationConfig + } = this._generationConfig; + const currentAuthType = this.currentAuthType; + + if (!currentModel || !currentAuthType) { + return undefined; + } + + // Check if model exists in registry - if so, it's not a runtime model + if (this.modelRegistry.hasModel(currentAuthType, currentModel)) { + // Current is a registry model, clear any previous RuntimeModelSnapshot for this authType + this.clearRuntimeModelSnapshotForAuthType(currentAuthType); + return undefined; + } + + // Check if we have valid credentials (apiKey + baseUrl) + const hasValidCredentials = + this._generationConfig.apiKey && this._generationConfig.baseUrl; + + if (!hasValidCredentials) { + return undefined; + } + + // Create or update RuntimeModelSnapshot + const snapshotId = this.buildRuntimeModelSnapshotId( + currentAuthType, + currentModel, + ); + const snapshot: RuntimeModelSnapshot = { + id: snapshotId, + authType: currentAuthType, + modelId: currentModel, + apiKey, + baseUrl, + apiKeyEnvKey, + generationConfig, + sources: { ...this.generationConfigSources }, + createdAt: Date.now(), + }; + + this.runtimeModelSnapshots.set(snapshotId, snapshot); + this.activeRuntimeModelSnapshotId = snapshotId; + + // Enforce per-authType limit + this.cleanupOldRuntimeModelSnapshots(); + + return snapshotId; + } + + /** + * Get the currently active RuntimeModelSnapshot. + * + * @returns The active RuntimeModelSnapshot, or undefined if no runtime model is active + */ + getActiveRuntimeModelSnapshot(): RuntimeModelSnapshot | undefined { + if (!this.activeRuntimeModelSnapshotId) { + return undefined; + } + return this.runtimeModelSnapshots.get(this.activeRuntimeModelSnapshotId); + } + + /** + * Get the ID of the currently active RuntimeModelSnapshot. + * + * @returns The active snapshot ID, or undefined if no runtime model is active + */ + getActiveRuntimeModelSnapshotId(): string | undefined { + return this.activeRuntimeModelSnapshotId; + } + + /** + * Switch to a RuntimeModelSnapshot. + * + * Applies the configuration from a previously captured RuntimeModelSnapshot. + * Uses state rollback pattern: creates a state snapshot before switching and + * restores it on error. + * + * @param snapshotId - The ID of the RuntimeModelSnapshot to switch to + */ + async switchToRuntimeModel(snapshotId: string): Promise { + const runtimeModelSnapshot = this.runtimeModelSnapshots.get(snapshotId); + if (!runtimeModelSnapshot) { + throw new Error(`Runtime model snapshot '${snapshotId}' not found`); + } + + const rollbackSnapshot = this.createStateSnapshotForRollback(); + + try { + const isAuthTypeChange = + runtimeModelSnapshot.authType !== this.currentAuthType; + this.currentAuthType = runtimeModelSnapshot.authType; + this.activeRuntimeModelSnapshotId = snapshotId; + + // Apply runtime configuration + this.strictModelProviderSelection = false; + this.hasManualCredentials = true; // Mark as manual to prevent provider override + + this._generationConfig.model = runtimeModelSnapshot.modelId; + this.generationConfigSources['model'] = { + kind: 'programmatic', + detail: 'runtimeModelSwitch', + }; + + if (runtimeModelSnapshot.apiKey) { + this._generationConfig.apiKey = runtimeModelSnapshot.apiKey; + this.generationConfigSources['apiKey'] = runtimeModelSnapshot.sources[ + 'apiKey' + ] || { + kind: 'programmatic', + detail: 'runtimeModelSwitch', + }; + } + + if (runtimeModelSnapshot.baseUrl) { + this._generationConfig.baseUrl = runtimeModelSnapshot.baseUrl; + this.generationConfigSources['baseUrl'] = runtimeModelSnapshot.sources[ + 'baseUrl' + ] || { + kind: 'programmatic', + detail: 'runtimeModelSwitch', + }; + } + + if (runtimeModelSnapshot.apiKeyEnvKey) { + this._generationConfig.apiKeyEnvKey = runtimeModelSnapshot.apiKeyEnvKey; + } + + // Apply generation config + if (runtimeModelSnapshot.generationConfig) { + Object.assign( + this._generationConfig, + runtimeModelSnapshot.generationConfig, + ); + } + + const requiresRefresh = isAuthTypeChange; + + if (this.onModelChange) { + await this.onModelChange( + runtimeModelSnapshot.authType, + requiresRefresh, + ); + } + } catch (error) { + this.rollbackToStateSnapshot(rollbackSnapshot); + throw error; + } + } + + /** + * Get the active RuntimeModelSnapshot as an AvailableModel option. + * + * Converts the active RuntimeModelSnapshot to an AvailableModel format for display + * in model lists. Returns undefined if no runtime model is active. + * + * @returns The runtime model as an AvailableModel option, or undefined + */ + private getRuntimeModelOption(): AvailableModel | undefined { + const snapshot = this.getActiveRuntimeModelSnapshot(); + if (!snapshot) { + return undefined; + } + + return { + id: snapshot.modelId, + label: snapshot.modelId, + authType: snapshot.authType, + /** + * `isVision` is for automatic switching of qwen-oauth vision model. + * Runtime models are basically specified via CLI arguments, env variables, + * or settings for other auth types. + */ + isVision: false, + contextWindowSize: snapshot.generationConfig?.contextWindowSize, + isRuntimeModel: true, + runtimeSnapshotId: snapshot.id, + }; + } + + /** + * Clear all RuntimeModelSnapshots for a specific authType. + * + * Removes all RuntimeModelSnapshots associated with the given authType. + * Called when switching to a registry model to avoid stale RuntimeModelSnapshots. + * + * @param authType - The authType whose snapshots should be cleared + */ + private clearRuntimeModelSnapshotForAuthType(authType: AuthType): void { + for (const [id, snapshot] of this.runtimeModelSnapshots.entries()) { + if (snapshot.authType === authType) { + this.runtimeModelSnapshots.delete(id); + if (this.activeRuntimeModelSnapshotId === id) { + this.activeRuntimeModelSnapshotId = undefined; + } + } + } + } + + /** + * Cleanup old RuntimeModelSnapshots to enforce per-authType limit. + * + * Keeps only the latest RuntimeModelSnapshot for each authType. + * Older snapshots are removed to prevent unbounded growth. + */ + private cleanupOldRuntimeModelSnapshots(): void { + const snapshotsByAuthType = new Map(); + + for (const snapshot of this.runtimeModelSnapshots.values()) { + const existing = snapshotsByAuthType.get(snapshot.authType); + if (!existing || snapshot.createdAt > existing.createdAt) { + snapshotsByAuthType.set(snapshot.authType, snapshot); + } + } + + this.runtimeModelSnapshots.clear(); + for (const snapshot of snapshotsByAuthType.values()) { + this.runtimeModelSnapshots.set(snapshot.id, snapshot); + } + + // Update active snapshot ID if it was removed + if ( + this.activeRuntimeModelSnapshotId && + !this.runtimeModelSnapshots.has(this.activeRuntimeModelSnapshotId) + ) { + this.activeRuntimeModelSnapshotId = undefined; + } + } } diff --git a/packages/core/src/models/types.ts b/packages/core/src/models/types.ts index 1a4d0c897..da3a2c5cf 100644 --- a/packages/core/src/models/types.ts +++ b/packages/core/src/models/types.ts @@ -8,6 +8,7 @@ import type { AuthType, ContentGeneratorConfig, } from '../core/contentGenerator.js'; +import type { ConfigSources } from '../utils/configResolver.js'; /** * Model capabilities configuration @@ -92,6 +93,12 @@ export interface AvailableModel { authType: AuthType; isVision?: boolean; contextWindowSize?: number; + + /** Whether this is a runtime model (not from modelProviders) */ + isRuntimeModel?: boolean; + + /** Runtime model snapshot ID (if isRuntimeModel is true) */ + runtimeSnapshotId?: string; } /** @@ -103,3 +110,35 @@ export interface ModelSwitchMetadata { /** Additional context */ context?: string; } + +/** + * Runtime model snapshot - captures complete model configuration from non-modelProviders sources + */ +export interface RuntimeModelSnapshot { + /** Snapshot unique identifier */ + id: string; + + /** Associated AuthType */ + authType: AuthType; + + /** Model ID */ + modelId: string; + + /** API Key (may come from env/cli/manual input) */ + apiKey?: string; + + /** Base URL (may come from env/cli/settings/credentials) */ + baseUrl?: string; + + /** Environment variable name (if apiKey comes from env) */ + apiKeyEnvKey?: string; + + /** Generation config (sampling parameters, etc.) */ + generationConfig?: ModelGenerationConfig; + + /** Configuration source tracking */ + sources: ConfigSources; + + /** Snapshot creation timestamp */ + createdAt: number; +} From 7b63b2477365262983a43c8eaaaa988382a43a9c Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Tue, 3 Feb 2026 14:17:32 +0800 Subject: [PATCH 26/49] fix: add hint for installing external extensions --- .../src/commands/extensions/consent.test.ts | 5 +++ .../cli/src/commands/extensions/consent.ts | 12 +++--- .../cli/src/commands/extensions/install.ts | 4 +- packages/cli/src/i18n/locales/de.js | 2 + packages/cli/src/i18n/locales/en.js | 2 + packages/cli/src/i18n/locales/ja.js | 2 + packages/cli/src/i18n/locales/pt.js | 2 + packages/cli/src/i18n/locales/ru.js | 2 + packages/cli/src/i18n/locales/zh.js | 2 + packages/core/src/config/config.ts | 3 ++ .../core/src/extension/claude-converter.ts | 1 + .../core/src/extension/extensionManager.ts | 41 +++++++++---------- packages/core/src/extension/marketplace.ts | 1 + 13 files changed, 49 insertions(+), 30 deletions(-) diff --git a/packages/cli/src/commands/extensions/consent.test.ts b/packages/cli/src/commands/extensions/consent.test.ts index 7d48a7c8c..da41ec04c 100644 --- a/packages/cli/src/commands/extensions/consent.test.ts +++ b/packages/cli/src/commands/extensions/consent.test.ts @@ -35,6 +35,7 @@ describe('extensionConsentString', () => { const config: ExtensionConfig = { name: 'test-extension', version: '1.0.0', + commands: [], }; const result = extensionConsentString(config); @@ -209,6 +210,7 @@ describe('requestConsentOrFail', () => { await requestConsentOrFail(mockRequestConsent, { extensionConfig: { name: 'test-extension', version: '1.0.0' }, + originSource: 'QwenCode', }); expect(mockRequestConsent).toHaveBeenCalled(); @@ -220,6 +222,7 @@ describe('requestConsentOrFail', () => { await expect( requestConsentOrFail(mockRequestConsent, { extensionConfig: { name: 'test-extension', version: '1.0.0' }, + originSource: 'QwenCode', }), ).rejects.toThrow('Installation cancelled for "test-extension".'); }); @@ -233,6 +236,7 @@ describe('requestConsentOrFail', () => { await requestConsentOrFail(mockRequestConsent, { extensionConfig, previousExtensionConfig: extensionConfig, + originSource: 'QwenCode', }); expect(mockRequestConsent).not.toHaveBeenCalled(); @@ -246,6 +250,7 @@ describe('requestConsentOrFail', () => { commands: ['command1'], previousExtensionConfig: { name: 'test-extension', version: '1.0.0' }, previousCommands: [], + originSource: 'QwenCode', }); expect(mockRequestConsent).toHaveBeenCalled(); diff --git a/packages/cli/src/commands/extensions/consent.ts b/packages/cli/src/commands/extensions/consent.ts index a248795de..2a8a9bcab 100644 --- a/packages/cli/src/commands/extensions/consent.ts +++ b/packages/cli/src/commands/extensions/consent.ts @@ -148,14 +148,15 @@ export function extensionConsentString( commands: string[] = [], skills: SkillConfig[] = [], subagents: SubagentConfig[] = [], - isGeminiExtension: boolean = false, + originSource: string = 'QwenCode', ): string { const output: string[] = []; - if (isGeminiExtension) { + if (originSource !== 'QwenCode') { output.push( - t( - '⚠️ You are installing a Gemini CLI extension. Some features may not work perfectly with Qwen Code.', - ), + `⚠️ ${t( + 'You are installing an extension from {{originSource}}. Some features may not work perfectly with Qwen Code.', + { originSource }, + )}`, ); } const mcpServerEntries = Object.entries(extensionConfig.mcpServers || {}); @@ -242,7 +243,6 @@ export const requestConsentOrFail = async ( commands, skills, subagents, - options.isGeminiExtension ?? false, ); if (previousExtensionConfig) { const previousExtensionConsent = extensionConsentString( diff --git a/packages/cli/src/commands/extensions/install.ts b/packages/cli/src/commands/extensions/install.ts index 62ae710f9..f7fda09df 100644 --- a/packages/cli/src/commands/extensions/install.ts +++ b/packages/cli/src/commands/extensions/install.ts @@ -10,7 +10,6 @@ import { ExtensionManager, parseInstallSource, } from '@qwen-code/qwen-code-core'; -import type { ExtensionRequestOptions } from '@qwen-code/qwen-code-core'; import { getErrorMessage } from '../../utils/errors.js'; import { isWorkspaceTrusted } from '../../config/trustedFolders.js'; import { loadSettings } from '../../config/settings.js'; @@ -48,8 +47,7 @@ export async function handleInstall(args: InstallArgs) { const requestConsent = args.consent ? () => Promise.resolve() - : (options?: ExtensionRequestOptions) => - requestConsentOrFail(requestConsentNonInteractive, options); + : requestConsentOrFail.bind(null, requestConsentNonInteractive); const workspaceDir = process.cwd(); const extensionManager = new ExtensionManager({ workspaceDir, diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index 44d982378..269da2dbc 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -424,6 +424,8 @@ export default { 'Diese Erweiterung wird folgende Unteragenten installieren:', 'Installation cancelled for "{{name}}".': 'Installation von "{{name}}" abgebrochen.', + 'You are installing an extension from {{originSource}}. Some features may not work perfectly with Qwen Code.': + 'Sie installieren eine Erweiterung von {{originSource}}. Einige Funktionen funktionieren möglicherweise nicht perfekt mit Qwen Code.', '--ref and --auto-update are not applicable for marketplace extensions.': '--ref und --auto-update sind nicht anwendbar für Marketplace-Erweiterungen.', 'Extension "{{name}}" installed successfully and enabled.': diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 95d908b11..59c191a07 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -438,6 +438,8 @@ export default { 'This extension will install the following subagents:', 'Installation cancelled for "{{name}}".': 'Installation cancelled for "{{name}}".', + 'You are installing an extension from {{originSource}}. Some features may not work perfectly with Qwen Code.': + 'You are installing an extension from {{originSource}}. Some features may not work perfectly with Qwen Code.', '--ref and --auto-update are not applicable for marketplace extensions.': '--ref and --auto-update are not applicable for marketplace extensions.', 'Extension "{{name}}" installed successfully and enabled.': diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index 201d1ee3d..2cfad0700 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -326,6 +326,8 @@ export default { 'List active extensions': '有効な拡張機能を一覧表示', 'Update extensions. Usage: update |--all': '拡張機能を更新。使い方: update <拡張機能名>|--all', + 'You are installing an extension from {{originSource}}. Some features may not work perfectly with Qwen Code.': + '{{originSource}} から拡張機能をインストールしています。一部の機能は Qwen Code で完全に動作しない可能性があります。', 'manage IDE integration': 'IDE連携を管理', 'check status of IDE integration': 'IDE連携の状態を確認', 'install required IDE companion for {{ideName}}': diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index 40410ce61..62e81def1 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -454,6 +454,8 @@ export default { 'Esta extensão instalará os seguintes subagentes:', 'Installation cancelled for "{{name}}".': 'Instalação cancelada para "{{name}}".', + 'You are installing an extension from {{originSource}}. Some features may not work perfectly with Qwen Code.': + 'Você está instalando uma extensão de {{originSource}}. Alguns recursos podem não funcionar perfeitamente com o Qwen Code.', '--ref and --auto-update are not applicable for marketplace extensions.': '--ref e --auto-update não são aplicáveis para extensões de marketplace.', 'Extension "{{name}}" installed successfully and enabled.': diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index 8bdee0b5c..4de08c6ef 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -443,6 +443,8 @@ export default { 'This extension will install the following subagents:': 'Это расширение установит следующие подагенты:', 'Installation cancelled for "{{name}}".': 'Установка "{{name}}" отменена.', + 'You are installing an extension from {{originSource}}. Some features may not work perfectly with Qwen Code.': + 'Вы устанавливаете расширение от {{originSource}}. Некоторые функции могут работать не идеально с Qwen Code.', '--ref and --auto-update are not applicable for marketplace extensions.': '--ref и --auto-update неприменимы для расширений из маркетплейса.', 'Extension "{{name}}" installed successfully and enabled.': diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 4f0523d7d..3f89f8dc8 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -420,6 +420,8 @@ export default { 'This extension will install the following subagents:': '此扩展将安装以下子代理:', 'Installation cancelled for "{{name}}".': '已取消安装 "{{name}}"。', + 'You are installing an extension from {{originSource}}. Some features may not work perfectly with Qwen Code.': + '您正在安装来自 {{originSource}} 的扩展。某些功能可能无法完美兼容 Qwen Code。', '--ref and --auto-update are not applicable for marketplace extensions.': '--ref 和 --auto-update 不适用于市场扩展。', 'Extension "{{name}}" installed successfully and enabled.': diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 63d3f57ba..f973e5f99 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -206,9 +206,12 @@ export interface GitCoAuthorSettings { email?: string; } +export type ExtensionOriginSource = 'QwenCode' | 'Claude' | 'Gemini'; + export interface ExtensionInstallMetadata { source: string; type: 'git' | 'local' | 'link' | 'github-release' | 'marketplace'; + originSource?: ExtensionOriginSource; releaseTag?: string; // Only present for github-release installs. ref?: string; autoUpdate?: boolean; diff --git a/packages/core/src/extension/claude-converter.ts b/packages/core/src/extension/claude-converter.ts index 84dab93cf..66703203d 100644 --- a/packages/core/src/extension/claude-converter.ts +++ b/packages/core/src/extension/claude-converter.ts @@ -699,6 +699,7 @@ async function resolvePluginSource( const installMetadata: ExtensionInstallMetadata = { source, type: 'git', + originSource: 'Claude', }; try { await downloadFromGitHubRelease(installMetadata, pluginDir); diff --git a/packages/core/src/extension/extensionManager.ts b/packages/core/src/extension/extensionManager.ts index 107a8813f..4553d99e9 100644 --- a/packages/core/src/extension/extensionManager.ts +++ b/packages/core/src/extension/extensionManager.ts @@ -55,7 +55,10 @@ import type { ExtensionSetting, ResolvedExtensionSetting, } from './extensionSettings.js'; -import type { TelemetrySettings } from '../config/config.js'; +import type { + ExtensionOriginSource, + TelemetrySettings, +} from '../config/config.js'; import { logExtensionUpdateEvent } from '../telemetry/loggers.js'; import { ExtensionDisableEvent, @@ -133,6 +136,7 @@ export enum ExtensionUpdateState { export type ExtensionRequestOptions = { extensionConfig: ExtensionConfig; + originSource: ExtensionOriginSource; commands?: string[]; skills?: SkillConfig[]; subagents?: SubagentConfig[]; @@ -140,7 +144,6 @@ export type ExtensionRequestOptions = { previousCommands?: string[]; previousSkills?: SkillConfig[]; previousSubagents?: SubagentConfig[]; - isGeminiExtension?: boolean; }; export interface ExtensionManagerOptions { @@ -244,10 +247,11 @@ async function loadCommandsFromDir(dir: string): Promise { async function convertGeminiOrClaudeExtension( extensionDir: string, pluginName?: string, -) { +): Promise<{ extensionDir: string; originSource: ExtensionOriginSource }> { let newExtensionDir = extensionDir; const qwenConfigPath = path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME); const geminiConfigPath = path.join(extensionDir, 'gemini-extension.json'); + let originSource: ExtensionOriginSource = 'QwenCode'; if (fs.existsSync(qwenConfigPath)) { // Already a Qwen extension — no conversion needed @@ -262,13 +266,15 @@ async function convertGeminiOrClaudeExtension( // THEN convert newExtensionDir = (await convertGeminiExtensionPackage(extensionDir)) .convertedDir; + originSource = 'Gemini'; } else if (pluginName) { // Claude plugin conversion (unchanged) newExtensionDir = ( await convertClaudePluginPackage(extensionDir, pluginName) ).convertedDir; + originSource = 'Claude'; } - return newExtensionDir; + return { extensionDir: newExtensionDir, originSource }; } // ============================================================================ @@ -812,23 +818,15 @@ export class ExtensionManager { } try { - // Save original path BEFORE conversion to detect Gemini origin - const originalSourcePath = localSourcePath; - - localSourcePath = await convertGeminiOrClaudeExtension( - localSourcePath, - installMetadata.pluginName, - ); - - // Detect if this was a Gemini extension (had gemini-extension.json but not qwen-extension.json) - const isGeminiExtension = - fs.existsSync( - path.join(originalSourcePath, 'gemini-extension.json'), - ) && - !fs.existsSync( - path.join(originalSourcePath, EXTENSIONS_CONFIG_FILENAME), + const { extensionDir, originSource } = + await convertGeminiOrClaudeExtension( + localSourcePath, + installMetadata.pluginName, ); + localSourcePath = extensionDir; + installMetadata.originSource = originSource; + newExtensionConfig = this.loadExtensionConfig({ extensionDir: localSourcePath, workspaceDir: currentDir, @@ -890,7 +888,7 @@ export class ExtensionManager { previousCommands, previousSkills, previousSubagents, - isGeminiExtension, + originSource: installMetadata.originSource, }); } else { await this.requestConsent({ @@ -902,7 +900,7 @@ export class ExtensionManager { previousCommands, previousSkills, previousSubagents, - isGeminiExtension, + originSource: installMetadata.originSource, }); } @@ -1100,6 +1098,7 @@ export class ExtensionManager { const installMetadata: ExtensionInstallMetadata = { source: extension.path, type: 'local', + originSource: extension.installMetadata?.originSource || 'QwenCode', }; await this.installExtension( installMetadata, diff --git a/packages/core/src/extension/marketplace.ts b/packages/core/src/extension/marketplace.ts index dec525579..e9e3cdd18 100644 --- a/packages/core/src/extension/marketplace.ts +++ b/packages/core/src/extension/marketplace.ts @@ -266,6 +266,7 @@ export async function parseInstallSource( if (marketplaceConfig) { installMetadata.type = 'marketplace'; installMetadata.marketplaceConfig = marketplaceConfig; + installMetadata.originSource = 'Claude'; } return installMetadata; From b15b9b7ef4a4c91804b2813bd277285912965df3 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Tue, 3 Feb 2026 14:30:55 +0800 Subject: [PATCH 27/49] fix ui --- .../cli/src/commands/extensions/consent.ts | 7 +++++-- .../core/src/extension/extensionManager.ts | 18 ++++-------------- 2 files changed, 9 insertions(+), 16 deletions(-) diff --git a/packages/cli/src/commands/extensions/consent.ts b/packages/cli/src/commands/extensions/consent.ts index 2a8a9bcab..0f2321075 100644 --- a/packages/cli/src/commands/extensions/consent.ts +++ b/packages/cli/src/commands/extensions/consent.ts @@ -153,10 +153,10 @@ export function extensionConsentString( const output: string[] = []; if (originSource !== 'QwenCode') { output.push( - `⚠️ ${t( + t( 'You are installing an extension from {{originSource}}. Some features may not work perfectly with Qwen Code.', { originSource }, - )}`, + ), ); } const mcpServerEntries = Object.entries(extensionConfig.mcpServers || {}); @@ -230,6 +230,7 @@ export const requestConsentOrFail = async ( if (!options) return; const { extensionConfig, + originSource = 'QwenCode', commands = [], skills = [], subagents = [], @@ -243,6 +244,7 @@ export const requestConsentOrFail = async ( commands, skills, subagents, + originSource, ); if (previousExtensionConfig) { const previousExtensionConsent = extensionConsentString( @@ -250,6 +252,7 @@ export const requestConsentOrFail = async ( previousCommands, previousSkills, previousSubagents, + originSource, ); if (previousExtensionConsent === extensionConsent) { return; diff --git a/packages/core/src/extension/extensionManager.ts b/packages/core/src/extension/extensionManager.ts index 4553d99e9..d229aa0d0 100644 --- a/packages/core/src/extension/extensionManager.ts +++ b/packages/core/src/extension/extensionManager.ts @@ -249,31 +249,21 @@ async function convertGeminiOrClaudeExtension( pluginName?: string, ): Promise<{ extensionDir: string; originSource: ExtensionOriginSource }> { let newExtensionDir = extensionDir; - const qwenConfigPath = path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME); - const geminiConfigPath = path.join(extensionDir, 'gemini-extension.json'); let originSource: ExtensionOriginSource = 'QwenCode'; - - if (fs.existsSync(qwenConfigPath)) { - // Already a Qwen extension — no conversion needed + const configFilePath = path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME); + if (fs.existsSync(configFilePath)) { newExtensionDir = extensionDir; - } else if (fs.existsSync(geminiConfigPath)) { - // VALIDATE FIRST (maintainer requirement) - if (!isGeminiExtensionConfig(extensionDir)) { - throw new Error( - `Invalid gemini-extension.json: missing required fields (name/version)`, - ); - } - // THEN convert + } else if (isGeminiExtensionConfig(extensionDir)) { newExtensionDir = (await convertGeminiExtensionPackage(extensionDir)) .convertedDir; originSource = 'Gemini'; } else if (pluginName) { - // Claude plugin conversion (unchanged) newExtensionDir = ( await convertClaudePluginPackage(extensionDir, pluginName) ).convertedDir; originSource = 'Claude'; } + // Claude plugin conversion not yet implemented return { extensionDir: newExtensionDir, originSource }; } From 8de68f967579c843177e7f29bc6cbe079bd1648f Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Wed, 4 Feb 2026 14:48:17 +0800 Subject: [PATCH 28/49] ci(sdk-release): use stable CLI tag for SDK releases - Add 'cli_ref' input parameter to specify CLI version to bundle - Auto-detect latest stable CLI tag when cli_ref not specified - Validate that stable SDK releases use tagged CLI versions (not main) - Record bundled CLI version in SDK dist and release notes This ensures SDK releases bundle stable, tested CLI code instead of potentially unstable main branch code. Co-authored-by: Qwen-Coder --- .github/workflows/release-sdk.yml | 77 +++++++++++++++++++++++++++++-- 1 file changed, 73 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release-sdk.yml b/.github/workflows/release-sdk.yml index 823c0055a..b5327f6d9 100644 --- a/.github/workflows/release-sdk.yml +++ b/.github/workflows/release-sdk.yml @@ -8,10 +8,15 @@ on: required: false type: 'string' ref: - description: 'The branch or ref (full git sha) to release from.' + description: 'The branch or ref (full git sha) to release SDK from.' required: true type: 'string' default: 'main' + cli_ref: + description: 'CLI ref to bundle (tag, branch, or commit). Default: latest stable CLI release tag (recommended for stable releases)' + required: false + type: 'string' + default: '' dry_run: description: 'Run a dry-run of the release process; no branches, npm packages or GitHub releases will be created.' required: true @@ -136,10 +141,62 @@ jobs: # This is required for nightly/preview because npm does not allow re-publishing the same version. npm version -w @qwen-code/sdk "${RELEASE_VERSION}" --no-git-tag-version --allow-same-version - - name: 'Build CLI Bundle' + - name: 'Determine CLI ref to bundle' + id: 'cli_ref' + env: + CLI_REF_INPUT: '${{ github.event.inputs.cli_ref }}' + IS_STABLE: '${{ steps.vars.outputs.is_nightly == false && steps.vars.outputs.is_preview == false }}' run: | - npm run build + if [[ -n "${CLI_REF_INPUT}" ]]; then + # User explicitly specified CLI ref + echo "CLI_REF=${CLI_REF_INPUT}" >> "$GITHUB_OUTPUT" + echo "Using user-specified CLI ref: ${CLI_REF_INPUT}" + else + # Auto-detect latest stable CLI release tag + # Exclude sdk-typescript tags, nightly, and preview tags + LATEST_CLI_TAG=$(git tag -l 'v*' --sort=-v:refname | grep -v 'sdk-typescript' | grep -v 'nightly' | grep -v 'preview' | head -1) + if [[ -z "${LATEST_CLI_TAG}" ]]; then + echo '::error::Could not find latest stable CLI tag' + exit 1 + fi + echo "CLI_REF=${LATEST_CLI_TAG}" >> "$GITHUB_OUTPUT" + echo "Using latest stable CLI tag: ${LATEST_CLI_TAG}" + fi + + - name: 'Validate CLI ref for stable releases' + env: + CLI_REF: '${{ steps.cli_ref.outputs.CLI_REF }}' + IS_STABLE: '${{ steps.vars.outputs.is_nightly == false && steps.vars.outputs.is_preview == false }}' + run: | + if [[ "${IS_STABLE}" == "true" ]]; then + # For stable releases, ensure CLI ref is a tag (not main or a branch) + if [[ "${CLI_REF}" == "main" ]] || [[ "${CLI_REF}" == "master" ]]; then + echo "::error::Stable SDK releases cannot bundle CLI from '${CLI_REF}' branch. Please specify a CLI release tag via 'cli_ref' input, or ensure the latest stable CLI tag is available." + exit 1 + fi + # Check if it's a valid tag + if ! git rev-parse "refs/tags/${CLI_REF}" >/dev/null 2>&1; then + echo "::warning::CLI ref '${CLI_REF}' is not a tag. Stable releases should use tagged CLI versions." + else + echo "✓ CLI ref '${CLI_REF}' is a valid tag" + fi + fi + + - name: 'Build CLI Bundle' + env: + CLI_REF: '${{ steps.cli_ref.outputs.CLI_REF }}' + run: | + echo "Building CLI from ref: ${CLI_REF}" + # Save current state + CURRENT_REF=$(git rev-parse HEAD) + # Checkout CLI ref + git checkout "${CLI_REF}" + # Install dependencies and build CLI + npm ci npm run bundle + # Return to original ref for SDK build + git checkout "${CURRENT_REF}" + echo "CLI bundle built successfully from ${CLI_REF}" - name: 'Run Tests' if: |- @@ -173,6 +230,14 @@ jobs: run: |- npm run build + - name: 'Record bundled CLI version' + env: + CLI_REF: '${{ steps.cli_ref.outputs.CLI_REF }}' + run: | + # Create a metadata file to record which CLI version was bundled + echo "${CLI_REF}" > packages/sdk-typescript/dist/BUNDLED_CLI_VERSION + echo "Bundled CLI version: ${CLI_REF}" + - name: 'Publish @qwen-code/sdk' working-directory: 'packages/sdk-typescript' run: |- @@ -219,6 +284,7 @@ jobs: IS_NIGHTLY: '${{ steps.vars.outputs.is_nightly }}' IS_PREVIEW: '${{ steps.vars.outputs.is_preview }}' REF: '${{ github.event.inputs.ref || github.sha }}' + CLI_REF: '${{ steps.cli_ref.outputs.CLI_REF }}' run: |- # For stable releases, use the release branch; for nightly/preview, use the current ref if [[ "${IS_NIGHTLY}" == "true" || "${IS_PREVIEW}" == "true" ]]; then @@ -229,11 +295,14 @@ jobs: PRERELEASE_FLAG="" fi + # Create release notes with CLI version info + NOTES="## Bundled CLI Version\n\nThis SDK release bundles CLI version: \`${CLI_REF}\`\n\n---\n\n" + gh release create "sdk-typescript-${RELEASE_TAG}" \ --target "${TARGET}" \ --title "SDK TypeScript Release ${RELEASE_TAG}" \ --notes-start-tag "sdk-typescript-${PREVIOUS_RELEASE_TAG}" \ - --generate-notes \ + --notes "${NOTES}$(gh release view "sdk-typescript-${PREVIOUS_RELEASE_TAG}" --json body -q '.body' 2>/dev/null || echo 'See commit history for changes.')" \ ${PRERELEASE_FLAG} - name: 'Create PR to merge release branch into main' From 417007d24350ae3ce70029291ee17ad0a9c93364 Mon Sep 17 00:00:00 2001 From: DragonnZhang <731557579@qq.com> Date: Wed, 4 Feb 2026 14:48:26 +0800 Subject: [PATCH 29/49] feat(query): add support for resuming sessions with session ID --- packages/sdk-typescript/src/query/Query.ts | 2 +- .../sdk-typescript/src/query/createQuery.ts | 1 + .../src/transport/ProcessTransport.ts | 4 +++ .../src/types/queryOptionsSchema.ts | 1 + packages/sdk-typescript/src/types/types.ts | 8 ++++++ .../test/unit/ProcessTransport.test.ts | 26 +++++++++++++++++++ .../sdk-typescript/test/unit/Query.test.ts | 13 ++++++++++ 7 files changed, 54 insertions(+), 1 deletion(-) diff --git a/packages/sdk-typescript/src/query/Query.ts b/packages/sdk-typescript/src/query/Query.ts index 540291769..7d1a936a4 100644 --- a/packages/sdk-typescript/src/query/Query.ts +++ b/packages/sdk-typescript/src/query/Query.ts @@ -91,7 +91,7 @@ export class Query implements AsyncIterable { ) { this.transport = transport; this.options = options; - this.sessionId = randomUUID(); + this.sessionId = options.resume ?? randomUUID(); this.inputStream = new Stream(); this.abortController = options.abortController ?? new AbortController(); this.isSingleTurn = singleTurn; diff --git a/packages/sdk-typescript/src/query/createQuery.ts b/packages/sdk-typescript/src/query/createQuery.ts index 3d0a76096..2a9842d0c 100644 --- a/packages/sdk-typescript/src/query/createQuery.ts +++ b/packages/sdk-typescript/src/query/createQuery.ts @@ -57,6 +57,7 @@ export function query({ allowedTools: options.allowedTools, authType: options.authType, includePartialMessages: options.includePartialMessages, + resume: options.resume, }); const queryOptions: QueryOptions = { diff --git a/packages/sdk-typescript/src/transport/ProcessTransport.ts b/packages/sdk-typescript/src/transport/ProcessTransport.ts index ff4518833..44c308e38 100644 --- a/packages/sdk-typescript/src/transport/ProcessTransport.ts +++ b/packages/sdk-typescript/src/transport/ProcessTransport.ts @@ -178,6 +178,10 @@ export class ProcessTransport implements Transport { args.push('--include-partial-messages'); } + if (this.options.resume) { + args.push('--resume', this.options.resume); + } + return args; } diff --git a/packages/sdk-typescript/src/types/queryOptionsSchema.ts b/packages/sdk-typescript/src/types/queryOptionsSchema.ts index a4794b3f8..948f1a1a0 100644 --- a/packages/sdk-typescript/src/types/queryOptionsSchema.ts +++ b/packages/sdk-typescript/src/types/queryOptionsSchema.ts @@ -164,6 +164,7 @@ export const QueryOptionsSchema = z ) .optional(), includePartialMessages: z.boolean().optional(), + resume: z.string().optional(), timeout: TimeoutConfigSchema.optional(), }) .strict(); diff --git a/packages/sdk-typescript/src/types/types.ts b/packages/sdk-typescript/src/types/types.ts index 3fbeca652..00f17d5d4 100644 --- a/packages/sdk-typescript/src/types/types.ts +++ b/packages/sdk-typescript/src/types/types.ts @@ -25,6 +25,7 @@ export type TransportOptions = { allowedTools?: string[]; authType?: string; includePartialMessages?: boolean; + resume?: string; }; type ToolInput = Record; @@ -402,6 +403,13 @@ export interface QueryOptions { */ includePartialMessages?: boolean; + /** + * Resume a previous session by providing its session ID. + * This is equivalent to using the `--resume` flag in the Qwen CLI. + * @example '123e4567-e89b-12d3-a456-426614174000' + */ + resume?: string; + /** * Timeout configuration for various SDK operations. * All values are in milliseconds. diff --git a/packages/sdk-typescript/test/unit/ProcessTransport.test.ts b/packages/sdk-typescript/test/unit/ProcessTransport.test.ts index 87bf6bc2a..0ee98c5b4 100644 --- a/packages/sdk-typescript/test/unit/ProcessTransport.test.ts +++ b/packages/sdk-typescript/test/unit/ProcessTransport.test.ts @@ -191,6 +191,32 @@ describe('ProcessTransport', () => { ); }); + it('should include --resume argument when provided', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + resume: '123e4567-e89b-12d3-a456-426614174000', + }; + + new ProcessTransport(options); + + expect(mockSpawn).toHaveBeenCalledWith( + 'qwen', + expect.arrayContaining([ + '--resume', + '123e4567-e89b-12d3-a456-426614174000', + ]), + expect.any(Object), + ); + }); + it('should throw if aborted before initialization', () => { mockPrepareSpawnInfo.mockReturnValue({ command: 'qwen', diff --git a/packages/sdk-typescript/test/unit/Query.test.ts b/packages/sdk-typescript/test/unit/Query.test.ts index fd38555fb..1de1c37e4 100644 --- a/packages/sdk-typescript/test/unit/Query.test.ts +++ b/packages/sdk-typescript/test/unit/Query.test.ts @@ -329,6 +329,19 @@ describe('Query', () => { await transport2.close(); }); + it('should use resume parameter as session ID if provided', async () => { + const resumeId = '123e4567-e89b-12d3-a456-426614174000'; + const query = new Query(transport, { + cwd: '/test', + resume: resumeId, + }); + + expect(query.getSessionId()).toBe(resumeId); + + await respondToInitialize(transport, query); + await query.close(); + }); + it('should handle initialization errors', async () => { const query = new Query(transport, { cwd: '/test', From 0128f65fe8912c4f9c67228c7aaa464949f7d325 Mon Sep 17 00:00:00 2001 From: skyfire Date: Wed, 4 Feb 2026 18:38:38 +0800 Subject: [PATCH 30/49] refactor structure --- packages/sdk-java/{ => client}/.editorconfig | 0 packages/sdk-java/client/.gitignore | 15 + packages/sdk-java/client/QWEN.md | 106 + packages/sdk-java/client/README.md | 178 + packages/sdk-java/{ => client}/checkstyle.xml | 0 packages/sdk-java/client/pom.xml | 206 ++ .../java/com/alibaba/acp/sdk/AcpClient.java | 174 + .../agent/notification/AgentNotification.java | 12 + .../notification/SessionNotification.java | 42 + .../agent/request/AuthenticateRequest.java | 31 + .../agent/request/ReadTextFileRequest.java | 58 + .../request/RequestPermissionRequest.java | 53 + .../agent/request/WriteTextFileRequest.java | 49 + .../terminal/CreateTerminalRequest.java | 79 + .../terminal/KillTerminalCommandRequest.java | 40 + .../terminal/ReleaseTerminalRequest.java | 40 + .../terminal/TerminalOutputRequest.java | 40 + .../terminal/WaitForTerminalExitRequest.java | 40 + .../agent/response/AuthenticateResponse.java | 11 + .../agent/response/InitializeResponse.java | 101 + .../agent/response/LoadSessionResponse.java | 21 + .../agent/response/NewSessionResponse.java | 30 + .../agent/response/PromptResponse.java | 21 + .../response/SetSessionModeResponse.java | 11 + .../notification/CancelNotification.java | 33 + .../notification/ClientNotification.java | 13 + .../client/request/InitializeRequest.java | 102 + .../client/request/LoadSessionRequest.java | 54 + .../client/request/NewSessionRequest.java | 46 + .../client/request/PromptRequest.java | 50 + .../client/request/SetSessionModeRequest.java | 40 + .../client/response/ReadTextFileResponse.java | 33 + .../response/RequestPermissionResponse.java | 40 + .../response/WriteTextFileResponse.java | 11 + .../terminal/CreateTerminalResponse.java | 20 + .../terminal/KillTerminalCommandResponse.java | 11 + .../terminal/ReleaseTerminalResponse.java | 11 + .../terminal/TerminalOutputResponse.java | 60 + .../terminal/WaitForTerminalExitResponse.java | 29 + .../domain/agent/AgentCapabilities.java | 189 + .../sdk/protocol/domain/agent/AgentInfo.java | 68 + .../sdk/protocol/domain/agent/AuthMethod.java | 68 + .../domain/client/ClientCapabilities.java | 120 + .../protocol/domain/client/ClientInfo.java | 68 + .../acp/sdk/protocol/domain/content/Diff.java | 34 + .../domain/content/ToolCallContent.java | 44 + .../domain/content/block/Annotations.java | 53 + .../domain/content/block/AudioContent.java | 40 + .../domain/content/block/ContentBlock.java | 18 + .../domain/content/block/ImageContent.java | 49 + .../domain/content/block/ResourceLink.java | 76 + .../domain/content/block/TextContent.java | 36 + .../embedded/BlobResourceContents.java | 13 + .../content/embedded/EmbeddedResource.java | 32 + .../content/embedded/ResourceContent.java | 26 + .../embedded/ResourceContentDeserializer.java | 24 + .../embedded/TextResourceContents.java | 13 + .../sdk/protocol/domain/mcp/HttpHeader.java | 25 + .../sdk/protocol/domain/mcp/McpServer.java | 75 + .../domain/permission/PermissionOption.java | 34 + .../permission/PermissionOptionKind.java | 17 + .../permission/PermissionOutcomeKind.java | 10 + .../permission/RequestPermissionOutcome.java | 34 + .../acp/sdk/protocol/domain/plan/Plan.java | 18 + .../sdk/protocol/domain/plan/PlanEntry.java | 34 + .../domain/plan/PlanEntryPriority.java | 14 + .../protocol/domain/plan/PlanEntryStatus.java | 14 + .../protocol/domain/session/SessionMode.java | 34 + .../domain/session/SessionModeState.java | 27 + .../protocol/domain/session/StopReason.java | 20 + .../AgentMessageChunkSessionUpdate.java | 21 + .../session/update/AvailableCommand.java | 34 + .../AvailableCommandsUpdateSessionUpdate.java | 22 + .../CurrentModeUpdateSessionUpdate.java | 20 + .../session/update/PlanSessionUpdate.java | 23 + .../domain/session/update/SessionUpdate.java | 23 + .../session/update/ToolCallSessionUpdate.java | 11 + .../update/ToolCallUpdateSessionUpdate.java | 11 + .../update/UnstructuredCommandInput.java | 16 + .../protocol/domain/terminal/EnvVariable.java | 25 + .../domain/tool/ToolCallLocation.java | 25 + .../protocol/domain/tool/ToolCallStatus.java | 17 + .../protocol/domain/tool/ToolCallUpdate.java | 82 + .../sdk/protocol/domain/tool/ToolKind.java | 35 + .../acp/sdk/protocol/jsonrpc/Error.java | 181 + .../sdk/protocol/jsonrpc/ExtNotification.java | 4 + .../acp/sdk/protocol/jsonrpc/ExtRequest.java | 4 + .../acp/sdk/protocol/jsonrpc/ExtResponse.java | 5 + .../acp/sdk/protocol/jsonrpc/Message.java | 22 + .../acp/sdk/protocol/jsonrpc/Meta.java | 48 + .../sdk/protocol/jsonrpc/MethodMessage.java | 72 + .../acp/sdk/protocol/jsonrpc/Request.java | 10 + .../acp/sdk/protocol/jsonrpc/Response.java | 35 + .../com/alibaba/acp/sdk/protocol/schema.json | 3105 +++++++++++++++++ .../com/alibaba/acp/sdk/session/Session.java | 312 ++ .../event/consumer/AgentEventConsumer.java | 118 + .../event/consumer/ContentEventConsumer.java | 110 + .../consumer/ContentEventSimpleConsumer.java | 84 + .../event/consumer/FileEventConsumer.java | 53 + .../consumer/FileEventSimpleConsumer.java | 168 + .../consumer/PermissionEventConsumer.java | 34 + .../consumer/PromptEndEventConsumer.java | 29 + .../event/consumer/TerminalEventConsumer.java | 110 + .../exception/EventConsumeException.java | 89 + .../exception/SessionLoadException.java | 65 + .../exception/SessionNewException.java | 73 + .../alibaba/acp/sdk/transport/Transport.java | 74 + .../transport/process/ProcessTransport.java | 228 ++ .../process/ProcessTransportOptions.java | 122 + .../sdk/utils/AgentInitializeException.java | 73 + .../acp/sdk/utils/MyConcurrentUtils.java | 83 + .../acp/sdk/utils/ThreadPoolConfig.java | 77 + .../com/alibaba/acp/sdk/utils/Timeout.java | 72 + .../alibaba/acp/sdk/utils/TransportUtils.java | 38 + .../session/PermissionOptionKindTest.java | 31 + .../client/session/PlanEntryPriorityTest.java | 27 + .../client/session/PlanEntryStatusTest.java | 27 + .../client/session/StopReasonTest.java | 35 + .../client/session/ToolCallStatusTest.java | 31 + .../protocol/client/session/ToolKindTest.java | 55 + .../alibaba/acp/sdk/session/SessionTest.java | 128 + .../com/alibaba/acp/sdk/test/EnumTest.java | 22 + .../src/test/resources/schema/schema.json | 3105 +++++++++++++++++ packages/sdk-java/qwencode/.editorconfig | 24 + packages/sdk-java/{ => qwencode}/.gitignore | 0 packages/sdk-java/{ => qwencode}/LICENSE | 0 packages/sdk-java/{ => qwencode}/QWEN.md | 0 packages/sdk-java/{ => qwencode}/README.md | 4 - packages/sdk-java/{ => qwencode}/RELEASE.md | 0 packages/sdk-java/qwencode/checkstyle.xml | 131 + packages/sdk-java/{ => qwencode}/pom.xml | 2 +- .../alibaba/qwen/code/cli/QwenCodeCli.java | 0 .../cli/protocol/data/AssistantContent.java | 0 .../cli/protocol/data/AssistantUsage.java | 0 .../protocol/data/CLIPermissionDenial.java | 0 .../code/cli/protocol/data/Capabilities.java | 0 .../code/cli/protocol/data/ExtendedUsage.java | 0 .../cli/protocol/data/InitializeConfig.java | 0 .../code/cli/protocol/data/ModelUsage.java | 0 .../cli/protocol/data/PermissionMode.java | 0 .../qwen/code/cli/protocol/data/Usage.java | 0 .../cli/protocol/data/behavior/Allow.java | 0 .../cli/protocol/data/behavior/Behavior.java | 0 .../code/cli/protocol/data/behavior/Deny.java | 0 .../code/cli/protocol/message/Message.java | 0 .../cli/protocol/message/MessageBase.java | 0 .../protocol/message/SDKResultMessage.java | 0 .../protocol/message/SDKSystemMessage.java | 0 .../cli/protocol/message/SDKUserMessage.java | 0 .../assistant/APIAssistantMessage.java | 0 .../assistant/SDKAssistantMessage.java | 0 .../assistant/SDKPartialAssistantMessage.java | 0 .../message/assistant/block/Annotation.java | 0 .../message/assistant/block/ContentBlock.java | 0 .../message/assistant/block/TextBlock.java | 0 .../assistant/block/ThinkingBlock.java | 0 .../assistant/block/ToolResultBlock.java | 0 .../message/assistant/block/ToolUseBlock.java | 0 .../event/ContentBlockDeltaEvent.java | 0 .../event/ContentBlockStartEvent.java | 0 .../event/ContentBlockStopEvent.java | 0 .../event/MessageStartStreamEvent.java | 0 .../event/MessageStopStreamEvent.java | 0 .../message/assistant/event/StreamEvent.java | 0 .../message/control/CLIControlRequest.java | 0 .../message/control/CLIControlResponse.java | 0 .../payload/CLIControlInitializeRequest.java | 0 .../payload/CLIControlInitializeResponse.java | 0 .../payload/CLIControlInterruptRequest.java | 0 .../payload/CLIControlPermissionRequest.java | 0 .../payload/CLIControlPermissionResponse.java | 0 .../payload/CLIControlSetModelRequest.java | 0 .../payload/CLIControlSetModelResponse.java | 0 .../CLIControlSetPermissionModeRequest.java | 0 .../payload/ControlRequestPayload.java | 0 .../payload/ControlResponsePayload.java | 0 .../qwen/code/cli/protocol/protocol.ts | 0 .../qwen/code/cli/session/Session.java | 0 .../consumers/AssistantContentConsumers.java | 0 .../AssistantContentSimpleConsumers.java | 0 .../consumers/SessionEventConsumers.java | 0 .../SessionEventSimpleConsumers.java | 0 .../exception/SessionControlException.java | 0 .../exception/SessionSendPromptException.java | 0 .../qwen/code/cli/transport/Transport.java | 0 .../code/cli/transport/TransportOptions.java | 0 .../transport/process/ProcessTransport.java | 0 .../process/TransportOptionsAdapter.java | 0 .../code/cli/utils/MyConcurrentUtils.java | 0 .../qwen/code/cli/utils/ThreadPoolConfig.java | 0 .../alibaba/qwen/code/cli/utils/Timeout.java | 0 .../qwen/code/cli/QwenCodeCliTest.java | 0 .../code/cli/example/QuickStartExample.java | 0 .../qwen/code/cli/example/SessionExample.java | 0 .../ThreadPoolConfigurationExample.java | 0 .../qwen/code/cli/session/SessionTest.java | 0 .../cli/transport/PermissionModeTest.java | 0 .../process/ProcessTransportTest.java | 0 198 files changed, 12718 insertions(+), 5 deletions(-) rename packages/sdk-java/{ => client}/.editorconfig (100%) create mode 100644 packages/sdk-java/client/.gitignore create mode 100644 packages/sdk-java/client/QWEN.md create mode 100644 packages/sdk-java/client/README.md rename packages/sdk-java/{ => client}/checkstyle.xml (100%) create mode 100644 packages/sdk-java/client/pom.xml create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/AcpClient.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/agent/notification/AgentNotification.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/agent/notification/SessionNotification.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/agent/request/AuthenticateRequest.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/agent/request/ReadTextFileRequest.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/agent/request/RequestPermissionRequest.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/agent/request/WriteTextFileRequest.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/agent/request/terminal/CreateTerminalRequest.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/agent/request/terminal/KillTerminalCommandRequest.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/agent/request/terminal/ReleaseTerminalRequest.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/agent/request/terminal/TerminalOutputRequest.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/agent/request/terminal/WaitForTerminalExitRequest.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/agent/response/AuthenticateResponse.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/agent/response/InitializeResponse.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/agent/response/LoadSessionResponse.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/agent/response/NewSessionResponse.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/agent/response/PromptResponse.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/agent/response/SetSessionModeResponse.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/client/notification/CancelNotification.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/client/notification/ClientNotification.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/client/request/InitializeRequest.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/client/request/LoadSessionRequest.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/client/request/NewSessionRequest.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/client/request/PromptRequest.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/client/request/SetSessionModeRequest.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/client/response/ReadTextFileResponse.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/client/response/RequestPermissionResponse.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/client/response/WriteTextFileResponse.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/client/response/terminal/CreateTerminalResponse.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/client/response/terminal/KillTerminalCommandResponse.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/client/response/terminal/ReleaseTerminalResponse.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/client/response/terminal/TerminalOutputResponse.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/client/response/terminal/WaitForTerminalExitResponse.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/agent/AgentCapabilities.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/agent/AgentInfo.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/agent/AuthMethod.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/client/ClientCapabilities.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/client/ClientInfo.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/content/Diff.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/content/ToolCallContent.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/content/block/Annotations.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/content/block/AudioContent.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/content/block/ContentBlock.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/content/block/ImageContent.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/content/block/ResourceLink.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/content/block/TextContent.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/content/embedded/BlobResourceContents.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/content/embedded/EmbeddedResource.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/content/embedded/ResourceContent.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/content/embedded/ResourceContentDeserializer.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/content/embedded/TextResourceContents.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/mcp/HttpHeader.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/mcp/McpServer.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/permission/PermissionOption.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/permission/PermissionOptionKind.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/permission/PermissionOutcomeKind.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/permission/RequestPermissionOutcome.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/plan/Plan.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/plan/PlanEntry.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/plan/PlanEntryPriority.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/plan/PlanEntryStatus.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/session/SessionMode.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/session/SessionModeState.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/session/StopReason.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/session/update/AgentMessageChunkSessionUpdate.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/session/update/AvailableCommand.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/session/update/AvailableCommandsUpdateSessionUpdate.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/session/update/CurrentModeUpdateSessionUpdate.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/session/update/PlanSessionUpdate.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/session/update/SessionUpdate.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/session/update/ToolCallSessionUpdate.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/session/update/ToolCallUpdateSessionUpdate.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/session/update/UnstructuredCommandInput.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/terminal/EnvVariable.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/tool/ToolCallLocation.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/tool/ToolCallStatus.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/tool/ToolCallUpdate.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/tool/ToolKind.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/jsonrpc/Error.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/jsonrpc/ExtNotification.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/jsonrpc/ExtRequest.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/jsonrpc/ExtResponse.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/jsonrpc/Message.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/jsonrpc/Meta.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/jsonrpc/MethodMessage.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/jsonrpc/Request.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/jsonrpc/Response.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/schema.json create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/session/Session.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/session/event/consumer/AgentEventConsumer.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/session/event/consumer/ContentEventConsumer.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/session/event/consumer/ContentEventSimpleConsumer.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/session/event/consumer/FileEventConsumer.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/session/event/consumer/FileEventSimpleConsumer.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/session/event/consumer/PermissionEventConsumer.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/session/event/consumer/PromptEndEventConsumer.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/session/event/consumer/TerminalEventConsumer.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/session/event/consumer/exception/EventConsumeException.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/session/exception/SessionLoadException.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/session/exception/SessionNewException.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/transport/Transport.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/transport/process/ProcessTransport.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/transport/process/ProcessTransportOptions.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/utils/AgentInitializeException.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/utils/MyConcurrentUtils.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/utils/ThreadPoolConfig.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/utils/Timeout.java create mode 100644 packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/utils/TransportUtils.java create mode 100644 packages/sdk-java/client/src/test/java/com/alibaba/acp/sdk/protocol/client/session/PermissionOptionKindTest.java create mode 100644 packages/sdk-java/client/src/test/java/com/alibaba/acp/sdk/protocol/client/session/PlanEntryPriorityTest.java create mode 100644 packages/sdk-java/client/src/test/java/com/alibaba/acp/sdk/protocol/client/session/PlanEntryStatusTest.java create mode 100644 packages/sdk-java/client/src/test/java/com/alibaba/acp/sdk/protocol/client/session/StopReasonTest.java create mode 100644 packages/sdk-java/client/src/test/java/com/alibaba/acp/sdk/protocol/client/session/ToolCallStatusTest.java create mode 100644 packages/sdk-java/client/src/test/java/com/alibaba/acp/sdk/protocol/client/session/ToolKindTest.java create mode 100644 packages/sdk-java/client/src/test/java/com/alibaba/acp/sdk/session/SessionTest.java create mode 100644 packages/sdk-java/client/src/test/java/com/alibaba/acp/sdk/test/EnumTest.java create mode 100644 packages/sdk-java/client/src/test/resources/schema/schema.json create mode 100644 packages/sdk-java/qwencode/.editorconfig rename packages/sdk-java/{ => qwencode}/.gitignore (100%) rename packages/sdk-java/{ => qwencode}/LICENSE (100%) rename packages/sdk-java/{ => qwencode}/QWEN.md (100%) rename packages/sdk-java/{ => qwencode}/README.md (98%) rename packages/sdk-java/{ => qwencode}/RELEASE.md (100%) create mode 100644 packages/sdk-java/qwencode/checkstyle.xml rename packages/sdk-java/{ => qwencode}/pom.xml (99%) rename packages/sdk-java/{ => qwencode}/src/main/java/com/alibaba/qwen/code/cli/QwenCodeCli.java (100%) rename packages/sdk-java/{ => qwencode}/src/main/java/com/alibaba/qwen/code/cli/protocol/data/AssistantContent.java (100%) rename packages/sdk-java/{ => qwencode}/src/main/java/com/alibaba/qwen/code/cli/protocol/data/AssistantUsage.java (100%) rename packages/sdk-java/{ => qwencode}/src/main/java/com/alibaba/qwen/code/cli/protocol/data/CLIPermissionDenial.java (100%) rename packages/sdk-java/{ => qwencode}/src/main/java/com/alibaba/qwen/code/cli/protocol/data/Capabilities.java (100%) rename packages/sdk-java/{ => qwencode}/src/main/java/com/alibaba/qwen/code/cli/protocol/data/ExtendedUsage.java (100%) rename packages/sdk-java/{ => qwencode}/src/main/java/com/alibaba/qwen/code/cli/protocol/data/InitializeConfig.java (100%) rename packages/sdk-java/{ => qwencode}/src/main/java/com/alibaba/qwen/code/cli/protocol/data/ModelUsage.java (100%) rename packages/sdk-java/{ => qwencode}/src/main/java/com/alibaba/qwen/code/cli/protocol/data/PermissionMode.java (100%) rename packages/sdk-java/{ => qwencode}/src/main/java/com/alibaba/qwen/code/cli/protocol/data/Usage.java (100%) rename packages/sdk-java/{ => qwencode}/src/main/java/com/alibaba/qwen/code/cli/protocol/data/behavior/Allow.java (100%) rename packages/sdk-java/{ => qwencode}/src/main/java/com/alibaba/qwen/code/cli/protocol/data/behavior/Behavior.java (100%) rename packages/sdk-java/{ => qwencode}/src/main/java/com/alibaba/qwen/code/cli/protocol/data/behavior/Deny.java (100%) rename packages/sdk-java/{ => qwencode}/src/main/java/com/alibaba/qwen/code/cli/protocol/message/Message.java (100%) rename packages/sdk-java/{ => qwencode}/src/main/java/com/alibaba/qwen/code/cli/protocol/message/MessageBase.java (100%) rename packages/sdk-java/{ => qwencode}/src/main/java/com/alibaba/qwen/code/cli/protocol/message/SDKResultMessage.java (100%) rename packages/sdk-java/{ => qwencode}/src/main/java/com/alibaba/qwen/code/cli/protocol/message/SDKSystemMessage.java (100%) rename packages/sdk-java/{ => qwencode}/src/main/java/com/alibaba/qwen/code/cli/protocol/message/SDKUserMessage.java (100%) rename packages/sdk-java/{ => qwencode}/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/APIAssistantMessage.java (100%) rename packages/sdk-java/{ => qwencode}/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/SDKAssistantMessage.java (100%) rename packages/sdk-java/{ => qwencode}/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/SDKPartialAssistantMessage.java (100%) rename packages/sdk-java/{ => qwencode}/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/Annotation.java (100%) rename packages/sdk-java/{ => qwencode}/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ContentBlock.java (100%) rename packages/sdk-java/{ => qwencode}/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/TextBlock.java (100%) rename packages/sdk-java/{ => qwencode}/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ThinkingBlock.java (100%) rename packages/sdk-java/{ => qwencode}/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ToolResultBlock.java (100%) rename packages/sdk-java/{ => qwencode}/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ToolUseBlock.java (100%) rename packages/sdk-java/{ => qwencode}/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/ContentBlockDeltaEvent.java (100%) rename packages/sdk-java/{ => qwencode}/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/ContentBlockStartEvent.java (100%) rename packages/sdk-java/{ => qwencode}/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/ContentBlockStopEvent.java (100%) rename packages/sdk-java/{ => qwencode}/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/MessageStartStreamEvent.java (100%) rename packages/sdk-java/{ => qwencode}/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/MessageStopStreamEvent.java (100%) rename packages/sdk-java/{ => qwencode}/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/StreamEvent.java (100%) rename packages/sdk-java/{ => qwencode}/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlRequest.java (100%) rename packages/sdk-java/{ => qwencode}/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlResponse.java (100%) rename packages/sdk-java/{ => qwencode}/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/CLIControlInitializeRequest.java (100%) rename packages/sdk-java/{ => qwencode}/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/CLIControlInitializeResponse.java (100%) rename packages/sdk-java/{ => qwencode}/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/CLIControlInterruptRequest.java (100%) rename packages/sdk-java/{ => qwencode}/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/CLIControlPermissionRequest.java (100%) rename packages/sdk-java/{ => qwencode}/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/CLIControlPermissionResponse.java (100%) rename packages/sdk-java/{ => qwencode}/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/CLIControlSetModelRequest.java (100%) rename packages/sdk-java/{ => qwencode}/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/CLIControlSetModelResponse.java (100%) rename packages/sdk-java/{ => qwencode}/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/CLIControlSetPermissionModeRequest.java (100%) rename packages/sdk-java/{ => qwencode}/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/ControlRequestPayload.java (100%) rename packages/sdk-java/{ => qwencode}/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/ControlResponsePayload.java (100%) rename packages/sdk-java/{ => qwencode}/src/main/java/com/alibaba/qwen/code/cli/protocol/protocol.ts (100%) rename packages/sdk-java/{ => qwencode}/src/main/java/com/alibaba/qwen/code/cli/session/Session.java (100%) rename packages/sdk-java/{ => qwencode}/src/main/java/com/alibaba/qwen/code/cli/session/event/consumers/AssistantContentConsumers.java (100%) rename packages/sdk-java/{ => qwencode}/src/main/java/com/alibaba/qwen/code/cli/session/event/consumers/AssistantContentSimpleConsumers.java (100%) rename packages/sdk-java/{ => qwencode}/src/main/java/com/alibaba/qwen/code/cli/session/event/consumers/SessionEventConsumers.java (100%) rename packages/sdk-java/{ => qwencode}/src/main/java/com/alibaba/qwen/code/cli/session/event/consumers/SessionEventSimpleConsumers.java (100%) rename packages/sdk-java/{ => qwencode}/src/main/java/com/alibaba/qwen/code/cli/session/exception/SessionControlException.java (100%) rename packages/sdk-java/{ => qwencode}/src/main/java/com/alibaba/qwen/code/cli/session/exception/SessionSendPromptException.java (100%) rename packages/sdk-java/{ => qwencode}/src/main/java/com/alibaba/qwen/code/cli/transport/Transport.java (100%) rename packages/sdk-java/{ => qwencode}/src/main/java/com/alibaba/qwen/code/cli/transport/TransportOptions.java (100%) rename packages/sdk-java/{ => qwencode}/src/main/java/com/alibaba/qwen/code/cli/transport/process/ProcessTransport.java (100%) rename packages/sdk-java/{ => qwencode}/src/main/java/com/alibaba/qwen/code/cli/transport/process/TransportOptionsAdapter.java (100%) rename packages/sdk-java/{ => qwencode}/src/main/java/com/alibaba/qwen/code/cli/utils/MyConcurrentUtils.java (100%) rename packages/sdk-java/{ => qwencode}/src/main/java/com/alibaba/qwen/code/cli/utils/ThreadPoolConfig.java (100%) rename packages/sdk-java/{ => qwencode}/src/main/java/com/alibaba/qwen/code/cli/utils/Timeout.java (100%) rename packages/sdk-java/{ => qwencode}/src/test/java/com/alibaba/qwen/code/cli/QwenCodeCliTest.java (100%) rename packages/sdk-java/{ => qwencode}/src/test/java/com/alibaba/qwen/code/cli/example/QuickStartExample.java (100%) rename packages/sdk-java/{ => qwencode}/src/test/java/com/alibaba/qwen/code/cli/example/SessionExample.java (100%) rename packages/sdk-java/{ => qwencode}/src/test/java/com/alibaba/qwen/code/cli/example/ThreadPoolConfigurationExample.java (100%) rename packages/sdk-java/{ => qwencode}/src/test/java/com/alibaba/qwen/code/cli/session/SessionTest.java (100%) rename packages/sdk-java/{ => qwencode}/src/test/java/com/alibaba/qwen/code/cli/transport/PermissionModeTest.java (100%) rename packages/sdk-java/{ => qwencode}/src/test/java/com/alibaba/qwen/code/cli/transport/process/ProcessTransportTest.java (100%) diff --git a/packages/sdk-java/.editorconfig b/packages/sdk-java/client/.editorconfig similarity index 100% rename from packages/sdk-java/.editorconfig rename to packages/sdk-java/client/.editorconfig diff --git a/packages/sdk-java/client/.gitignore b/packages/sdk-java/client/.gitignore new file mode 100644 index 000000000..93d412274 --- /dev/null +++ b/packages/sdk-java/client/.gitignore @@ -0,0 +1,15 @@ +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +# Mac +.DS_Store + +# Maven +log/ +target/ + +/docs/ +/logs/ diff --git a/packages/sdk-java/client/QWEN.md b/packages/sdk-java/client/QWEN.md new file mode 100644 index 000000000..3a3f74380 --- /dev/null +++ b/packages/sdk-java/client/QWEN.md @@ -0,0 +1,106 @@ +# ACP SDK Project Context + +## Project Overview + +The `acp-sdk` is a Java SDK implementation for the Agent Client Protocol (ACP), which is a protocol for communication between AI agents and client applications. The SDK provides a standardized way to interact with AI agents that support the Agent Client Protocol, enabling features like session management, file system operations, terminal commands, and tool calls. + +The project is structured as a Maven-based Java project with the following key characteristics: + +- **Group ID**: com.alibaba +- **Artifact ID**: acp-sdk +- **Version**: 0.0.1-alpha +- **Description**: The agent client protocol Java SDK +- **Java Version**: 1.8+ + +## Architecture and Components + +### Core Components + +1. **AcpClient**: Main client class that manages connections to ACP-compatible agents +2. **Session**: Represents a conversation session with an agent +3. **Transport**: Handles the underlying communication protocol (JSON-RPC over stdio, HTTP, etc.) +4. **Protocol Definitions**: Generated from JSON schema, defining all ACP message types + +### Key Features + +- Session management (create, load, manage conversations) +- File system operations (read/write text files) +- Terminal command execution +- Tool call handling and permission management +- Support for rich content types (text, images, audio, resources) +- Integration with Model Context Protocol (MCP) servers + +### Protocol Structure + +The SDK implements the Agent Client Protocol based on a comprehensive JSON schema that defines: + +- Request/Response types for agent-client communication +- Notification mechanisms for real-time updates +- Error handling and capability negotiation +- Content blocks for various data types +- Tool call definitions and execution flows + +## Building and Running + +### Prerequisites + +- Java 8 or higher +- Maven 3.6.0 or higher + +### Build Commands + +```bash +# Compile the project +mvn compile + +# Run tests +mvn test + +# Package the JAR +mvn package + +# Install to local repository +mvn install +``` + +### Testing + +The project includes comprehensive unit tests covering: + +- Protocol message generation from JSON schema +- Session management functionality +- Permission handling workflows +- Content type processing + +### Development Conventions + +- Code follows standard Java conventions +- Uses SLF4J for logging +- Leverages Apache Commons Lang3 and IO utilities +- Uses FastJSON2 for JSON serialization/deserialization +- Follows JSON-RPC 2.0 specification for messaging + +## Usage Examples + +Based on the test files, typical usage involves: + +1. Creating a Transport instance (e.g., ProcessTransport for running agent processes) +2. Initializing an AcpClient with appropriate capabilities +3. Creating or loading sessions +4. Sending prompts and handling responses through event consumers + +The SDK supports advanced features like permission management for sensitive operations, file system access, and integration with external MCP servers for extended tooling capabilities. + +## Dependencies + +Key dependencies include: + +- SLF4J API for logging +- Apache Commons Lang3 and IO for utility functions +- FastJSON2 for JSON processing +- JUnit 5 for testing +- Logback Classic for testing logging + +## Project Status + +This is an alpha version of the SDK, suggesting it's in early development stages. The project appears to be actively maintained by Alibaba's skyfire developer team and is designed to support AI agent integration in enterprise environments. diff --git a/packages/sdk-java/client/README.md b/packages/sdk-java/client/README.md new file mode 100644 index 000000000..3a1b6ef45 --- /dev/null +++ b/packages/sdk-java/client/README.md @@ -0,0 +1,178 @@ +# ACP Client SDK (Agent Client Protocol Java SDK For Client) + +The ACP Client SDK is a Java Software Development Kit for communicating with AI agents that support the Agent Client Protocol (ACP). It provides a standardized way to interact with AI agents that support the Agent Client Protocol, enabling features like session management, file system operations, terminal commands, and tool calls. + +## Project Overview + +The ACP SDK implements the Agent Client Protocol, allowing client applications to communicate with AI agents. The SDK provides the following core features: + +- Session management (create, load, manage conversations) +- File system operations (read/write text files) +- Terminal command execution +- Tool call handling and permission management +- Support for rich content types (text, images, audio, resources) +- Integration with Model Context Protocol (MCP) servers + +## Requirements + +- Java 8 or higher +- Maven 3.6.0 or higher + +## Features + +- **Standardized Protocol**: Implements ACP protocol to ensure interoperability with various ACP-compatible agents +- **Flexible Transport Layer**: Supports multiple transport mechanisms (stdio, HTTP, etc.) +- **Session Management**: Complete session lifecycle management +- **Permission Control**: Fine-grained permission management to protect sensitive operations +- **Content Block Handling**: Support for multiple data types in content blocks +- **MCP Integration**: Integration with external MCP servers for extended tooling capabilities + +## Installation + +### Maven + +Add the following dependency to your `pom.xml` file: + +```xml + + com.alibaba + acp-sdk + 0.0.1-alpha + +``` + +### Gradle + +Add the following to your `build.gradle` file: + +```gradle +implementation 'com.alibaba:acp-sdk:0.0.1-alpha' +``` + +## Quick Start + +The following is a simple example showing how to use the ACP SDK to create a client and establish a session: + +```java +@Test +public void testSession() throws AgentInitializeException, SessionNewException, IOException { + // Create an ACP client with a process transport + AcpClient acpClient = new AcpClient( + new ProcessTransport(new ProcessTransportOptions().setCommandArgs(new String[] {"qwen", "--acp", "--experimental-skills", "-y"}))); + + try { + // Send a prompt to the agent + acpClient.sendPrompt(Collections.singletonList(new TextContent("你是谁")), + new AgentEventConsumer().setContentEventConsumer(new ContentEventSimpleConsumer() { + @Override + public void onAgentMessageChunkSessionUpdate(AgentMessageChunkSessionUpdate sessionUpdate) { + logger.info(sessionUpdate.toString()); + } + + @Override + public void onAvailableCommandsUpdateSessionUpdate(AvailableCommandsUpdateSessionUpdate sessionUpdate) { + logger.info(sessionUpdate.toString()); + } + + @Override + public void onCurrentModeUpdateSessionUpdate(CurrentModeUpdateSessionUpdate sessionUpdate) { + logger.info(sessionUpdate.toString()); + } + + @Override + public void onPlanSessionUpdate(PlanSessionUpdate sessionUpdate) { + logger.info(sessionUpdate.toString()); + } + + @Override + public void onToolCallUpdateSessionUpdate(ToolCallUpdateSessionUpdate sessionUpdate) { + logger.info(sessionUpdate.toString()); + } + + @Override + public void onToolCallSessionUpdate(ToolCallSessionUpdate sessionUpdate) { + logger.info(sessionUpdate.toString()); + } + })); + } finally { + // Close the client when done + acpClient.close(); + } +} +``` + +## Architecture Components + +### Core Components + +1. **AcpClient**: Main client class that manages connections to ACP-compatible agents +2. **Session**: Represents a conversation session with an agent +3. **Transport**: Handles the underlying communication protocol (JSON-RPC over stdio, HTTP, etc.) +4. **Protocol Definitions**: Generated from JSON schema, defining all ACP message types + +### Protocol Structure + +The SDK implements the Agent Client Protocol based on a comprehensive JSON schema that defines: + +- Request/Response types for agent-client communication +- Notification mechanisms for real-time updates +- Error handling and capability negotiation +- Content blocks for various data types +- Tool call definitions and execution flows + +## Use Cases + +- AI agent integration in enterprise applications +- Automated scripting and task execution +- File system operation automation +- Terminal command execution and result processing +- Integration with external services and tools + +## Development + +### Build + +```bash +# Compile the project +mvn compile + +# Run tests +mvn test + +# Package the JAR +mvn package + +# Install to local repository +mvn install +``` + +### Testing + +The project includes comprehensive unit tests covering: + +- Protocol message generation from JSON schema +- Session management functionality +- Permission handling workflows +- Content type processing + +## Dependencies + +Key dependencies include: + +- SLF4J API for logging +- Apache Commons Lang3 and IO for utility functions +- FastJSON2 for JSON serialization/deserialization +- JUnit 5 for testing +- Logback Classic for testing logging + +## License + +This project is licensed under the Apache 2.0 License. See the [LICENSE](LICENSE) file for details. + +## Contributing + +Contributions are welcome! Please submit issues and pull requests. + +## Support + +If you encounter any problems, please submit an issue report through GitHub Issues. diff --git a/packages/sdk-java/checkstyle.xml b/packages/sdk-java/client/checkstyle.xml similarity index 100% rename from packages/sdk-java/checkstyle.xml rename to packages/sdk-java/client/checkstyle.xml diff --git a/packages/sdk-java/client/pom.xml b/packages/sdk-java/client/pom.xml new file mode 100644 index 000000000..b85781536 --- /dev/null +++ b/packages/sdk-java/client/pom.xml @@ -0,0 +1,206 @@ + + 4.0.0 + com.alibaba + acp-sdk + jar + 0.0.1-alpha + acp-sdk + The agent client protocol Java SDK + + https://maven.apache.org + + + Apache 2 + https://www.apache.org/licenses/LICENSE-2.0.txt + repo + A business-friendly OSS license + + + + https://github.com/QwenLM/qwen-code + scm:git:https://github.com/QwenLM/qwen-code.git + + + 1.8 + 1.8 + UTF-8 + 3.6.0 + 0.8.12 + 5.14.1 + 2.0.60 + 2.0.17 + 3.20.0 + 3.13.0 + 0.8.0 + 2.2.1 + 2.9.1 + 1.5 + 2.21.0 + + + + + + org.junit + junit-bom + pom + ${junit5.version} + import + + + + + + org.slf4j + slf4j-api + ${slf4j-api.version} + compile + + + org.apache.commons + commons-lang3 + ${commons-lang3.version} + + + commons-io + commons-io + ${commons-io.version} + + + com.alibaba.fastjson2 + fastjson2 + ${fastjson2.version} + + + org.junit.jupiter + junit-jupiter + test + + + ch.qos.logback + logback-classic + 1.3.16 + test + + + + + + + org.apache.maven.plugins + maven-checkstyle-plugin + ${checkstyle-maven-plugin.version} + + checkstyle.xml + + + + + check + + + + + + org.jacoco + jacoco-maven-plugin + ${jacoco-maven-plugin.version} + + + + prepare-agent + + + + report + test + + report + + + + + + org.sonatype.central + central-publishing-maven-plugin + ${central-publishing-maven-plugin.version} + true + + central + + + + + org.apache.maven.plugins + maven-source-plugin + ${maven-source-plugin.version} + + + attach-sources + + jar-no-fork + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + ${maven-javadoc-plugin.version} + + + attach-javadocs + + jar + + + + + + org.apache.maven.plugins + maven-gpg-plugin + ${maven-gpg-plugin.version} + + + sign-artifacts + verify + + sign + + + + + + + + + Alibaba Group + https://github.com/alibaba + + + + skyfire + skyfire + gengwei.gw(at)alibaba-inc.com + + Developer + Designer + + +8 + https://github.com/gwinthis + + + + + + central + https://central.sonatype.com/repository/maven-snapshots/ + + + central + https://central.sonatype.org/service/local/staging/deploy/maven2/ + + + diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/AcpClient.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/AcpClient.java new file mode 100644 index 000000000..1f3d8957d --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/AcpClient.java @@ -0,0 +1,174 @@ +package com.alibaba.acp.sdk; + +import java.io.IOException; +import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; + +import com.alibaba.acp.sdk.protocol.agent.response.InitializeResponse; +import com.alibaba.acp.sdk.protocol.client.request.InitializeRequest; +import com.alibaba.acp.sdk.protocol.client.request.InitializeRequest.InitializeRequestParams; +import com.alibaba.acp.sdk.protocol.client.request.LoadSessionRequest; +import com.alibaba.acp.sdk.protocol.client.request.LoadSessionRequest.LoadSessionRequestParams; +import com.alibaba.acp.sdk.protocol.client.request.NewSessionRequest; +import com.alibaba.acp.sdk.protocol.client.request.NewSessionRequest.NewSessionRequestParams; +import com.alibaba.acp.sdk.protocol.agent.response.LoadSessionResponse; +import com.alibaba.acp.sdk.protocol.agent.response.NewSessionResponse; +import com.alibaba.acp.sdk.protocol.domain.content.block.ContentBlock; +import com.alibaba.acp.sdk.session.Session; +import com.alibaba.acp.sdk.session.event.consumer.AgentEventConsumer; +import com.alibaba.acp.sdk.session.exception.SessionLoadException; +import com.alibaba.acp.sdk.session.exception.SessionNewException; +import com.alibaba.acp.sdk.transport.Transport; +import com.alibaba.acp.sdk.utils.AgentInitializeException; +import com.alibaba.acp.sdk.utils.TransportUtils; + +import org.apache.commons.lang3.Validate; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * ACP (Agent Client Protocol) Client Implementation + * This class provides the main entry point for communicating with AI agents, responsible for initializing connections, managing sessions, and other + * operations. + * + * @author SkyFire + * @version 0.0.1 + */ +public class AcpClient { + private final Transport transport; + private Session session; + private static final Logger logger = LoggerFactory.getLogger(AcpClient.class); + + /** + * Constructs a new ACP client instance + * This constructor starts the transport layer and sends an initialization request to the agent side, completing the protocol handshake process. + * + * @param transport Transport instance for communication with the agent, cannot be null + * @throws AgentInitializeException Thrown when an error occurs during initialization + */ + public AcpClient(Transport transport) throws AgentInitializeException { + this(transport, new InitializeRequestParams()); + } + + /** + * Constructs a new ACP client instance + * This constructor starts the transport layer and sends an initialization request to the agent side, completing the protocol handshake process. + * + * @param transport Transport instance for communication with the agent, cannot be null + * @param initializeRequestParams Initialization request parameters, including client capabilities and other information, cannot be null + * @throws AgentInitializeException Thrown when an error occurs during initialization + */ + public AcpClient(Transport transport, InitializeRequestParams initializeRequestParams) throws AgentInitializeException { + Validate.notNull(transport, "transport can't be null"); + this.transport = transport; + + try { + transport.start(); + } catch (IOException e) { + throw new AgentInitializeException("transport start error") + .addContextValue("initializeRequestParams", initializeRequestParams); + } + Validate.notNull(initializeRequestParams, "initializeRequestParams can't be null"); + + InitializeResponse initializeResponse; + try { + InitializeRequest initializeRequest = new InitializeRequest(initializeRequestParams); + logger.debug("start to initialize agent {}", initializeRequest); + initializeResponse = TransportUtils.inputWaitForOneLine(transport, initializeRequest, InitializeResponse.class); + logger.debug("initialize response: {}", initializeResponse); + } catch (IOException | ExecutionException | InterruptedException | TimeoutException e) { + throw new AgentInitializeException("agent transport error") + .addContextValue("initializeRequestParams", initializeRequestParams); + } + if (initializeResponse.getError() != null) { + throw new AgentInitializeException("agent initialize error") + .addContextValue("initializeRequestParams", initializeRequestParams) + .addContextValue("initializeResponse", initializeResponse); + } + } + + public void sendPrompt(List prompts, AgentEventConsumer agentEventConsumer) throws IOException, SessionNewException { + newSession(); + session.sendPrompt(prompts, agentEventConsumer); + } + + /** + * Creates a new session + * Sends a new session request to the agent and creates a Session instance based on the response. + * + * @return Session object representing the newly created session + * @throws SessionNewException Thrown when an error occurs during session creation + */ + public Session newSession() throws SessionNewException { + return newSession(new NewSessionRequestParams()); + } + + /** + * Creates a new session + * Sends a new session request to the agent and creates a Session instance based on the response. + * + * @param newSessionRequestParams New session request parameters, including session configuration information + * @return Session object representing the newly created session + * @throws SessionNewException Thrown when an error occurs during session creation + */ + public Session newSession(NewSessionRequestParams newSessionRequestParams) throws SessionNewException { + Validate.notNull(newSessionRequestParams, "newSessionRequestParams can't be null"); + NewSessionRequest newSessionRequest = new NewSessionRequest(newSessionRequestParams); + NewSessionResponse newSessionResponse; + try { + logger.debug("start to new session {}", newSessionRequest); + newSessionResponse = TransportUtils.inputWaitForOneLine(transport, newSessionRequest, NewSessionResponse.class); + } catch (IOException | ExecutionException | InterruptedException | TimeoutException e) { + throw new SessionNewException("transport inputWaitForOneLine error", e) + .addContextValue("newSessionRequestParams", newSessionRequestParams) + .addContextValue("newSessionRequest", newSessionRequest); + } + logger.debug("new session response: {}", newSessionResponse); + if (newSessionResponse.getError() != null) { + throw new SessionNewException("new session error") + .addContextValue("newSessionRequest", newSessionRequest) + .addContextValue("newSessionRequestParams", newSessionRequestParams) + .addContextValue("newSessionResponse", newSessionResponse); + } + + session = new Session(transport, new LoadSessionRequestParams() + .setSessionId(newSessionResponse.getResult().getSessionId()) + .setCwd(newSessionRequestParams.getCwd()) + .setMcpServers(newSessionRequestParams.getMcpServers()) + ); + return session; + } + + /** + * Loads an existing session + * Sends a load session request to the agent and creates a Session instance representing the session. + * + * @param loadSessionRequestParams Load session request parameters, including session identifier and other information + * @return Session object representing the loaded session + * @throws SessionLoadException Thrown when an error occurs during session loading + */ + public Session loadSession(LoadSessionRequestParams loadSessionRequestParams) + throws SessionLoadException { + LoadSessionRequest loadSessionRequest = new LoadSessionRequest(loadSessionRequestParams); + logger.debug("start to load session {}", loadSessionRequest); + LoadSessionResponse loadSessionResponse; + try { + loadSessionResponse = TransportUtils.inputWaitForOneLine(transport, loadSessionRequest, LoadSessionResponse.class); + } catch (IOException | ExecutionException | InterruptedException | TimeoutException e) { + throw new SessionLoadException("transport inputWaitForOneLine error", e) + .addContextValue("loadSessionRequestParams", loadSessionRequestParams) + .addContextValue("loadSessionRequest", loadSessionRequest); + } + logger.debug("loadSessionResponse: {}", loadSessionResponse); + session = new Session(transport, loadSessionRequestParams); + return session; + } + + public void close() throws IOException { + if (transport != null) { + transport.close(); + } + //ThreadPoolConfig.shutdown(); + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/agent/notification/AgentNotification.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/agent/notification/AgentNotification.java new file mode 100644 index 000000000..67650c3ae --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/agent/notification/AgentNotification.java @@ -0,0 +1,12 @@ +package com.alibaba.acp.sdk.protocol.agent.notification; + +import com.alibaba.acp.sdk.protocol.jsonrpc.MethodMessage; + +public class AgentNotification

extends MethodMessage

{ + public AgentNotification() { + } + + public AgentNotification(String method, P params) { + super(method, params); + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/agent/notification/SessionNotification.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/agent/notification/SessionNotification.java new file mode 100644 index 000000000..d2ef8c618 --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/agent/notification/SessionNotification.java @@ -0,0 +1,42 @@ +package com.alibaba.acp.sdk.protocol.agent.notification; + +import com.alibaba.acp.sdk.protocol.agent.notification.SessionNotification.SessionNotificationParams; +import com.alibaba.acp.sdk.protocol.domain.session.update.SessionUpdate; +import com.alibaba.fastjson2.annotation.JSONType; + +@JSONType(typeName = "session/update") +public class SessionNotification extends AgentNotification { + public SessionNotification() { + super(); + this.method = "session/update"; + } + + public static class SessionNotificationParams { + private String sessionId; + private SessionUpdate update; + + public SessionNotificationParams() { + } + + public SessionNotificationParams(String sessionId, SessionUpdate update) { + this.sessionId = sessionId; + this.update = update; + } + + public String getSessionId() { + return sessionId; + } + + public void setSessionId(String sessionId) { + this.sessionId = sessionId; + } + + public SessionUpdate getUpdate() { + return update; + } + + public void setUpdate(SessionUpdate update) { + this.update = update; + } + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/agent/request/AuthenticateRequest.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/agent/request/AuthenticateRequest.java new file mode 100644 index 000000000..7774eaca4 --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/agent/request/AuthenticateRequest.java @@ -0,0 +1,31 @@ +package com.alibaba.acp.sdk.protocol.agent.request; + +import com.alibaba.acp.sdk.protocol.jsonrpc.Meta; +import com.alibaba.acp.sdk.protocol.jsonrpc.Request; +import com.alibaba.fastjson2.annotation.JSONType; + +import static com.alibaba.acp.sdk.protocol.agent.request.AuthenticateRequest.AuthenticateRequestParams; + +@JSONType(typeName = "authenticate") +public class AuthenticateRequest extends Request { + public AuthenticateRequest() { + this(new AuthenticateRequestParams()); + } + + public AuthenticateRequest(AuthenticateRequestParams requestParams) { + super("authenticate", requestParams); + } + + public static class AuthenticateRequestParams extends Meta { + private String methodId; + + // Getters and setters + public String getMethodId() { + return methodId; + } + + public void setMethodId(String methodId) { + this.methodId = methodId; + } + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/agent/request/ReadTextFileRequest.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/agent/request/ReadTextFileRequest.java new file mode 100644 index 000000000..189680c1c --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/agent/request/ReadTextFileRequest.java @@ -0,0 +1,58 @@ +package com.alibaba.acp.sdk.protocol.agent.request; + +import com.alibaba.acp.sdk.protocol.jsonrpc.Meta; +import com.alibaba.acp.sdk.protocol.jsonrpc.Request; +import com.alibaba.fastjson2.annotation.JSONType; + +import static com.alibaba.acp.sdk.protocol.agent.request.ReadTextFileRequest.ReadTextFileRequestParams; + +@JSONType(typeName = "fs/read_text_file") +public class ReadTextFileRequest extends Request { + public ReadTextFileRequest() { + this(new ReadTextFileRequestParams()); + } + + public ReadTextFileRequest(ReadTextFileRequestParams requestParams) { + super("fs/read_text_file", requestParams); + } + + public static class ReadTextFileRequestParams extends Meta { + private String sessionId; + private String path; + private Integer line; + private Integer limit; + + // Getters and setters + public String getSessionId() { + return sessionId; + } + + public void setSessionId(String sessionId) { + this.sessionId = sessionId; + } + + public String getPath() { + return path; + } + + public void setPath(String path) { + this.path = path; + } + + public Integer getLine() { + return line; + } + + public void setLine(Integer line) { + this.line = line; + } + + public Integer getLimit() { + return limit; + } + + public void setLimit(Integer limit) { + this.limit = limit; + } + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/agent/request/RequestPermissionRequest.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/agent/request/RequestPermissionRequest.java new file mode 100644 index 000000000..d8c34c242 --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/agent/request/RequestPermissionRequest.java @@ -0,0 +1,53 @@ +package com.alibaba.acp.sdk.protocol.agent.request; + +import java.util.List; + +import com.alibaba.acp.sdk.protocol.domain.permission.PermissionOption; +import com.alibaba.acp.sdk.protocol.domain.tool.ToolCallUpdate; +import com.alibaba.acp.sdk.protocol.jsonrpc.Meta; +import com.alibaba.acp.sdk.protocol.jsonrpc.Request; +import com.alibaba.fastjson2.annotation.JSONType; + +import static com.alibaba.acp.sdk.protocol.agent.request.RequestPermissionRequest.RequestPermissionRequestParams; + +@JSONType(typeName = "session/request_permission") +public class RequestPermissionRequest extends Request { + public RequestPermissionRequest() { + this(new RequestPermissionRequestParams()); + } + + public RequestPermissionRequest(RequestPermissionRequestParams requestParams) { + super("session/request_permission", requestParams); + } + + public static class RequestPermissionRequestParams extends Meta { + private String sessionId; + private ToolCallUpdate toolCall; + private List options; + + // Getters and setters + public String getSessionId() { + return sessionId; + } + + public void setSessionId(String sessionId) { + this.sessionId = sessionId; + } + + public ToolCallUpdate getToolCall() { + return toolCall; + } + + public void setToolCall(ToolCallUpdate toolCall) { + this.toolCall = toolCall; + } + + public List getOptions() { + return options; + } + + public void setOptions(List options) { + this.options = options; + } + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/agent/request/WriteTextFileRequest.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/agent/request/WriteTextFileRequest.java new file mode 100644 index 000000000..373ffac7c --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/agent/request/WriteTextFileRequest.java @@ -0,0 +1,49 @@ +package com.alibaba.acp.sdk.protocol.agent.request; + +import com.alibaba.acp.sdk.protocol.jsonrpc.Meta; +import com.alibaba.acp.sdk.protocol.jsonrpc.Request; +import com.alibaba.fastjson2.annotation.JSONType; + +import static com.alibaba.acp.sdk.protocol.agent.request.WriteTextFileRequest.WriteTextFileRequestParams; + +@JSONType(typeName = "fs/write_text_file") +public class WriteTextFileRequest extends Request { + public WriteTextFileRequest() { + this(new WriteTextFileRequestParams()); + } + + public WriteTextFileRequest(WriteTextFileRequestParams requestParams) { + super("fs/write_text_file", requestParams); + } + + public static class WriteTextFileRequestParams extends Meta { + private String sessionId; + private String path; + private String content; + + // Getters and setters + public String getSessionId() { + return sessionId; + } + + public void setSessionId(String sessionId) { + this.sessionId = sessionId; + } + + public String getPath() { + return path; + } + + public void setPath(String path) { + this.path = path; + } + + public String getContent() { + return content; + } + + public void setContent(String content) { + this.content = content; + } + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/agent/request/terminal/CreateTerminalRequest.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/agent/request/terminal/CreateTerminalRequest.java new file mode 100644 index 000000000..4269276f8 --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/agent/request/terminal/CreateTerminalRequest.java @@ -0,0 +1,79 @@ +package com.alibaba.acp.sdk.protocol.agent.request.terminal; + +import java.util.List; + +import com.alibaba.acp.sdk.protocol.domain.terminal.EnvVariable; +import com.alibaba.acp.sdk.protocol.jsonrpc.Meta; +import com.alibaba.acp.sdk.protocol.jsonrpc.Request; +import com.alibaba.fastjson2.annotation.JSONType; + +import static com.alibaba.acp.sdk.protocol.agent.request.terminal.CreateTerminalRequest.CreateTerminalRequestParams; + +@JSONType(typeName = "terminal/create") +public class CreateTerminalRequest extends Request { + public CreateTerminalRequest() { + this(new CreateTerminalRequestParams()); + } + + public CreateTerminalRequest(CreateTerminalRequestParams requestParams) { + super("terminal/create", requestParams); + } + + public static class CreateTerminalRequestParams extends Meta { + private String sessionId; + private String command; + private List args; + private String cwd; + private List env; + private Long outputByteLimit; + + // Getters and setters + public String getSessionId() { + return sessionId; + } + + public void setSessionId(String sessionId) { + this.sessionId = sessionId; + } + + public String getCommand() { + return command; + } + + public void setCommand(String command) { + this.command = command; + } + + public List getArgs() { + return args; + } + + public void setArgs(List args) { + this.args = args; + } + + public String getCwd() { + return cwd; + } + + public void setCwd(String cwd) { + this.cwd = cwd; + } + + public List getEnv() { + return env; + } + + public void setEnv(List env) { + this.env = env; + } + + public Long getOutputByteLimit() { + return outputByteLimit; + } + + public void setOutputByteLimit(Long outputByteLimit) { + this.outputByteLimit = outputByteLimit; + } + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/agent/request/terminal/KillTerminalCommandRequest.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/agent/request/terminal/KillTerminalCommandRequest.java new file mode 100644 index 000000000..9d6b9a57e --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/agent/request/terminal/KillTerminalCommandRequest.java @@ -0,0 +1,40 @@ +package com.alibaba.acp.sdk.protocol.agent.request.terminal; + +import com.alibaba.acp.sdk.protocol.jsonrpc.Meta; +import com.alibaba.acp.sdk.protocol.jsonrpc.Request; +import com.alibaba.fastjson2.annotation.JSONType; + +import static com.alibaba.acp.sdk.protocol.agent.request.terminal.KillTerminalCommandRequest.KillTerminalCommandRequestParams; + +@JSONType(typeName = "terminal/kill") +public class KillTerminalCommandRequest extends Request { + public KillTerminalCommandRequest() { + this(new KillTerminalCommandRequestParams()); + } + + public KillTerminalCommandRequest(KillTerminalCommandRequestParams requestParams) { + super("terminal/kill", requestParams); + } + + public static class KillTerminalCommandRequestParams extends Meta { + private String sessionId; + private String terminalId; + + // Getters and setters + public String getSessionId() { + return sessionId; + } + + public void setSessionId(String sessionId) { + this.sessionId = sessionId; + } + + public String getTerminalId() { + return terminalId; + } + + public void setTerminalId(String terminalId) { + this.terminalId = terminalId; + } + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/agent/request/terminal/ReleaseTerminalRequest.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/agent/request/terminal/ReleaseTerminalRequest.java new file mode 100644 index 000000000..3ebe76457 --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/agent/request/terminal/ReleaseTerminalRequest.java @@ -0,0 +1,40 @@ +package com.alibaba.acp.sdk.protocol.agent.request.terminal; + +import com.alibaba.acp.sdk.protocol.jsonrpc.Meta; +import com.alibaba.acp.sdk.protocol.jsonrpc.Request; +import com.alibaba.fastjson2.annotation.JSONType; + +import static com.alibaba.acp.sdk.protocol.agent.request.terminal.ReleaseTerminalRequest.ReleaseTerminalRequestParams; + +@JSONType(typeName = "terminal/release") +public class ReleaseTerminalRequest extends Request { + public ReleaseTerminalRequest() { + this(new ReleaseTerminalRequestParams()); + } + + public ReleaseTerminalRequest(ReleaseTerminalRequestParams requestParams) { + super("terminal/release", requestParams); + } + + public static class ReleaseTerminalRequestParams extends Meta { + private String sessionId; + private String terminalId; + + // Getters and setters + public String getSessionId() { + return sessionId; + } + + public void setSessionId(String sessionId) { + this.sessionId = sessionId; + } + + public String getTerminalId() { + return terminalId; + } + + public void setTerminalId(String terminalId) { + this.terminalId = terminalId; + } + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/agent/request/terminal/TerminalOutputRequest.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/agent/request/terminal/TerminalOutputRequest.java new file mode 100644 index 000000000..e847e55fd --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/agent/request/terminal/TerminalOutputRequest.java @@ -0,0 +1,40 @@ +package com.alibaba.acp.sdk.protocol.agent.request.terminal; + +import com.alibaba.acp.sdk.protocol.jsonrpc.Meta; +import com.alibaba.acp.sdk.protocol.jsonrpc.Request; +import com.alibaba.fastjson2.annotation.JSONType; + +import static com.alibaba.acp.sdk.protocol.agent.request.terminal.TerminalOutputRequest.TerminalOutputRequestParams; + +@JSONType(typeName = "terminal/output") +public class TerminalOutputRequest extends Request { + public TerminalOutputRequest() { + this(new TerminalOutputRequestParams()); + } + + public TerminalOutputRequest(TerminalOutputRequestParams requestParams) { + super("terminal/output", requestParams); + } + + public static class TerminalOutputRequestParams extends Meta { + private String sessionId; + private String terminalId; + + // Getters and setters + public String getSessionId() { + return sessionId; + } + + public void setSessionId(String sessionId) { + this.sessionId = sessionId; + } + + public String getTerminalId() { + return terminalId; + } + + public void setTerminalId(String terminalId) { + this.terminalId = terminalId; + } + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/agent/request/terminal/WaitForTerminalExitRequest.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/agent/request/terminal/WaitForTerminalExitRequest.java new file mode 100644 index 000000000..f58d74c4b --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/agent/request/terminal/WaitForTerminalExitRequest.java @@ -0,0 +1,40 @@ +package com.alibaba.acp.sdk.protocol.agent.request.terminal; + +import com.alibaba.acp.sdk.protocol.jsonrpc.Meta; +import com.alibaba.acp.sdk.protocol.jsonrpc.Request; +import com.alibaba.fastjson2.annotation.JSONType; + +import static com.alibaba.acp.sdk.protocol.agent.request.terminal.WaitForTerminalExitRequest.WaitForTerminalExitRequestParams; + +@JSONType(typeName = "terminal/wait_for_exit") +public class WaitForTerminalExitRequest extends Request { + public WaitForTerminalExitRequest() { + this(new WaitForTerminalExitRequestParams()); + } + + public WaitForTerminalExitRequest(WaitForTerminalExitRequestParams requestParams) { + super("terminal/wait_for_exit", requestParams); + } + + public static class WaitForTerminalExitRequestParams extends Meta { + private String sessionId; + private String terminalId; + + // Getters and setters + public String getSessionId() { + return sessionId; + } + + public void setSessionId(String sessionId) { + this.sessionId = sessionId; + } + + public String getTerminalId() { + return terminalId; + } + + public void setTerminalId(String terminalId) { + this.terminalId = terminalId; + } + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/agent/response/AuthenticateResponse.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/agent/response/AuthenticateResponse.java new file mode 100644 index 000000000..b28f731a2 --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/agent/response/AuthenticateResponse.java @@ -0,0 +1,11 @@ +package com.alibaba.acp.sdk.protocol.agent.response; + +import com.alibaba.acp.sdk.protocol.jsonrpc.Response; + +import static com.alibaba.acp.sdk.protocol.agent.response.AuthenticateResponse.AuthenticateResponseResult; + +public class AuthenticateResponse extends Response { + public static class AuthenticateResponseResult { + // Empty result class as per schema + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/agent/response/InitializeResponse.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/agent/response/InitializeResponse.java new file mode 100644 index 000000000..dd9e9e572 --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/agent/response/InitializeResponse.java @@ -0,0 +1,101 @@ +package com.alibaba.acp.sdk.protocol.agent.response; + +import java.util.List; + +import com.alibaba.acp.sdk.protocol.domain.agent.AgentCapabilities; +import com.alibaba.acp.sdk.protocol.domain.agent.AgentInfo; +import com.alibaba.acp.sdk.protocol.domain.agent.AuthMethod; +import com.alibaba.acp.sdk.protocol.jsonrpc.Response; + +import static com.alibaba.acp.sdk.protocol.agent.response.InitializeResponse.InitializeResponseResult; + +/** + * Initialize Response Class + * + * Represents the agent's response to the client's initialization request, containing agent capability information, authentication methods, etc. + */ +public class InitializeResponse extends Response { + /** + * Initialize Response Result Class + * + * Contains initialization response data such as protocol version, agent capabilities, agent information, and authentication methods. + */ + public static class InitializeResponseResult { + private int protocolVersion; + private AgentCapabilities agentCapabilities; + private AgentInfo agentInfo; + private List authMethods; + + /** + * Gets the protocol version + * + * @return Protocol version number + */ + public int getProtocolVersion() { + return protocolVersion; + } + + /** + * Sets the protocol version + * + * @param protocolVersion Protocol version number + */ + public void setProtocolVersion(int protocolVersion) { + this.protocolVersion = protocolVersion; + } + + /** + * Gets the agent capabilities + * + * @return Agent capabilities object + */ + public AgentCapabilities getAgentCapabilities() { + return agentCapabilities; + } + + /** + * Sets the agent capabilities + * + * @param agentCapabilities Agent capabilities object + */ + public void setAgentCapabilities(AgentCapabilities agentCapabilities) { + this.agentCapabilities = agentCapabilities; + } + + /** + * Gets the agent information + * + * @return Agent information object + */ + public AgentInfo getAgentInfo() { + return agentInfo; + } + + /** + * Sets the agent information + * + * @param agentInfo Agent information object + */ + public void setAgentInfo(AgentInfo agentInfo) { + this.agentInfo = agentInfo; + } + + /** + * Gets the authentication method list + * + * @return Authentication method list + */ + public List getAuthMethods() { + return authMethods; + } + + /** + * Sets the authentication method list + * + * @param authMethods Authentication method list + */ + public void setAuthMethods(List authMethods) { + this.authMethods = authMethods; + } + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/agent/response/LoadSessionResponse.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/agent/response/LoadSessionResponse.java new file mode 100644 index 000000000..45d9d2702 --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/agent/response/LoadSessionResponse.java @@ -0,0 +1,21 @@ +package com.alibaba.acp.sdk.protocol.agent.response; + +import com.alibaba.acp.sdk.protocol.domain.session.SessionModeState; +import com.alibaba.acp.sdk.protocol.jsonrpc.Response; + +import static com.alibaba.acp.sdk.protocol.agent.response.LoadSessionResponse.LoadSessionResponseResult; + +public class LoadSessionResponse extends Response { + public static class LoadSessionResponseResult { + private SessionModeState modes; + + // Getters and setters + public SessionModeState getModes() { + return modes; + } + + public void setModes(SessionModeState modes) { + this.modes = modes; + } + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/agent/response/NewSessionResponse.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/agent/response/NewSessionResponse.java new file mode 100644 index 000000000..6a1fff8e8 --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/agent/response/NewSessionResponse.java @@ -0,0 +1,30 @@ +package com.alibaba.acp.sdk.protocol.agent.response; + +import com.alibaba.acp.sdk.protocol.domain.session.SessionModeState; +import com.alibaba.acp.sdk.protocol.jsonrpc.Response; + +import static com.alibaba.acp.sdk.protocol.agent.response.NewSessionResponse.NewSessionResponseResult; + +public class NewSessionResponse extends Response { + public static class NewSessionResponseResult { + private String sessionId; + private SessionModeState modes; + + // Getters and setters + public String getSessionId() { + return sessionId; + } + + public void setSessionId(String sessionId) { + this.sessionId = sessionId; + } + + public SessionModeState getModes() { + return modes; + } + + public void setModes(SessionModeState modes) { + this.modes = modes; + } + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/agent/response/PromptResponse.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/agent/response/PromptResponse.java new file mode 100644 index 000000000..5511a96be --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/agent/response/PromptResponse.java @@ -0,0 +1,21 @@ +package com.alibaba.acp.sdk.protocol.agent.response; + +import com.alibaba.acp.sdk.protocol.domain.session.StopReason; +import com.alibaba.acp.sdk.protocol.jsonrpc.Response; + +import static com.alibaba.acp.sdk.protocol.agent.response.PromptResponse.PromptResponseResult; + +public class PromptResponse extends Response { + public static class PromptResponseResult { + private StopReason stopReason; + + // Getters and setters + public StopReason getStopReason() { + return stopReason; + } + + public void setStopReason(StopReason stopReason) { + this.stopReason = stopReason; + } + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/agent/response/SetSessionModeResponse.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/agent/response/SetSessionModeResponse.java new file mode 100644 index 000000000..b2e1a7914 --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/agent/response/SetSessionModeResponse.java @@ -0,0 +1,11 @@ +package com.alibaba.acp.sdk.protocol.agent.response; + +import com.alibaba.acp.sdk.protocol.jsonrpc.Response; + +import static com.alibaba.acp.sdk.protocol.agent.response.SetSessionModeResponse.SetSessionModeResponseResult; + +public class SetSessionModeResponse extends Response { + public static class SetSessionModeResponseResult { + // Empty result class as per schema + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/client/notification/CancelNotification.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/client/notification/CancelNotification.java new file mode 100644 index 000000000..258f1665b --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/client/notification/CancelNotification.java @@ -0,0 +1,33 @@ +package com.alibaba.acp.sdk.protocol.client.notification; + +import com.alibaba.acp.sdk.protocol.client.notification.CancelNotification.CancelNotificationParams; + +public class CancelNotification extends ClientNotification { + public CancelNotification() { + super(); + this.method = "session/cancel"; + } + + public CancelNotification(String method, CancelNotificationParams params) { + super(method, params); + } + + public static class CancelNotificationParams { + private String sessionId; + + public CancelNotificationParams() { + } + + public CancelNotificationParams(String sessionId) { + this.sessionId = sessionId; + } + + public String getSessionId() { + return sessionId; + } + + public void setSessionId(String sessionId) { + this.sessionId = sessionId; + } + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/client/notification/ClientNotification.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/client/notification/ClientNotification.java new file mode 100644 index 000000000..6cef7c276 --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/client/notification/ClientNotification.java @@ -0,0 +1,13 @@ +package com.alibaba.acp.sdk.protocol.client.notification; + +import com.alibaba.acp.sdk.protocol.jsonrpc.MethodMessage; + +public class ClientNotification

extends MethodMessage

{ + public ClientNotification() { + super(); + } + + public ClientNotification(String method, P params) { + super(method, params); + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/client/request/InitializeRequest.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/client/request/InitializeRequest.java new file mode 100644 index 000000000..1fe2a3dd2 --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/client/request/InitializeRequest.java @@ -0,0 +1,102 @@ +package com.alibaba.acp.sdk.protocol.client.request; + +import com.alibaba.acp.sdk.protocol.domain.client.ClientCapabilities; +import com.alibaba.acp.sdk.protocol.domain.client.ClientInfo; +import com.alibaba.acp.sdk.protocol.jsonrpc.Meta; +import com.alibaba.acp.sdk.protocol.jsonrpc.Request; +import com.alibaba.fastjson2.annotation.JSONType; + +import static com.alibaba.acp.sdk.protocol.client.request.InitializeRequest.InitializeRequestParams; + +/** + * Initialize Request Class + * + * Used to send client initialization information to the agent, including protocol version, client capabilities, and client information. + */ +@JSONType(typeName = "initialize") +public class InitializeRequest extends Request { + /** + * Constructs an initialization request with default parameters + */ + public InitializeRequest() { + this(new InitializeRequestParams()); + } + + /** + * Constructs an initialization request with specified parameters + * + * @param requestParams Initialization request parameters + */ + public InitializeRequest(InitializeRequestParams requestParams) { + super("initialize", requestParams); + } + + /** + * Initialize Request Parameters Class + * + * Contains initialization information such as protocol version, client capabilities, and client information. + */ + public static class InitializeRequestParams extends Meta { + private int protocolVersion; + private ClientCapabilities clientCapabilities = new ClientCapabilities(); + private ClientInfo clientInfo; + + /** + * Gets the protocol version + * + * @return Protocol version number + */ + public int getProtocolVersion() { + return protocolVersion; + } + + /** + * Sets the protocol version + * + * @param protocolVersion Protocol version number + */ + public void setProtocolVersion(int protocolVersion) { + this.protocolVersion = protocolVersion; + } + + /** + * Gets the client capabilities + * + * @return Client capabilities object + */ + public ClientCapabilities getClientCapabilities() { + return clientCapabilities; + } + + /** + * Sets the client capabilities + * + * @param clientCapabilities Client capabilities object + * @return Current object instance, facilitating method chaining + */ + public InitializeRequestParams setClientCapabilities(ClientCapabilities clientCapabilities) { + this.clientCapabilities = clientCapabilities; + return this; + } + + /** + * Gets the client information + * + * @return Client information object + */ + public ClientInfo getClientInfo() { + return clientInfo; + } + + /** + * Sets the client information + * + * @param clientInfo Client information object + * @return Current object instance, facilitating method chaining + */ + public InitializeRequestParams setClientInfo(ClientInfo clientInfo) { + this.clientInfo = clientInfo; + return this; + } + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/client/request/LoadSessionRequest.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/client/request/LoadSessionRequest.java new file mode 100644 index 000000000..f47e77377 --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/client/request/LoadSessionRequest.java @@ -0,0 +1,54 @@ +package com.alibaba.acp.sdk.protocol.client.request; + +import java.util.List; + +import com.alibaba.acp.sdk.protocol.domain.mcp.McpServer; +import com.alibaba.acp.sdk.protocol.jsonrpc.Meta; +import com.alibaba.acp.sdk.protocol.jsonrpc.Request; +import com.alibaba.fastjson2.annotation.JSONType; + +import static com.alibaba.acp.sdk.protocol.client.request.LoadSessionRequest.LoadSessionRequestParams; + +@JSONType(typeName = "session/load") +public class LoadSessionRequest extends Request { + public LoadSessionRequest() { + this(new LoadSessionRequestParams()); + } + + public LoadSessionRequest(LoadSessionRequestParams requestParams) { + super("session/load", requestParams); + } + + public static class LoadSessionRequestParams extends Meta { + private String sessionId; + private String cwd; + private List mcpServers; + + public String getSessionId() { + return sessionId; + } + + public LoadSessionRequestParams setSessionId(String sessionId) { + this.sessionId = sessionId; + return this; + } + + public String getCwd() { + return cwd; + } + + public LoadSessionRequestParams setCwd(String cwd) { + this.cwd = cwd; + return this; + } + + public List getMcpServers() { + return mcpServers; + } + + public LoadSessionRequestParams setMcpServers(List mcpServers) { + this.mcpServers = mcpServers; + return this; + } + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/client/request/NewSessionRequest.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/client/request/NewSessionRequest.java new file mode 100644 index 000000000..3017f0473 --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/client/request/NewSessionRequest.java @@ -0,0 +1,46 @@ +package com.alibaba.acp.sdk.protocol.client.request; + +import java.util.ArrayList; +import java.util.List; + +import com.alibaba.acp.sdk.protocol.domain.mcp.McpServer; +import com.alibaba.acp.sdk.protocol.jsonrpc.Meta; +import com.alibaba.acp.sdk.protocol.jsonrpc.Request; +import com.alibaba.fastjson2.annotation.JSONType; + +import static com.alibaba.acp.sdk.protocol.client.request.NewSessionRequest.NewSessionRequestParams; + +@JSONType(typeName = "session/new") +public class NewSessionRequest extends Request { + public NewSessionRequest() { + this(new NewSessionRequestParams()); + } + + public NewSessionRequest(NewSessionRequestParams requestParams) { + super("session/new", requestParams); + } + + public static class NewSessionRequestParams extends Meta { + private String cwd = System.getProperty("user.dir"); + private List mcpServers = new ArrayList<>(); + + // Getters and setters + public String getCwd() { + return cwd; + } + + public void setCwd(String cwd) { + this.cwd = cwd; + } + + public List getMcpServers() { + return mcpServers; + } + + public void setMcpServers(List mcpServers) { + this.mcpServers = mcpServers; + } + + // Inner class for McpServer + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/client/request/PromptRequest.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/client/request/PromptRequest.java new file mode 100644 index 000000000..11d1430e0 --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/client/request/PromptRequest.java @@ -0,0 +1,50 @@ +package com.alibaba.acp.sdk.protocol.client.request; + +import java.util.List; + +import com.alibaba.acp.sdk.protocol.client.request.PromptRequest.PromptRequestParams; +import com.alibaba.acp.sdk.protocol.domain.content.block.ContentBlock; +import com.alibaba.acp.sdk.protocol.jsonrpc.Meta; +import com.alibaba.acp.sdk.protocol.jsonrpc.Request; +import com.alibaba.fastjson2.annotation.JSONType; + +@JSONType(typeName = "session/prompt") +public class PromptRequest extends Request { + public PromptRequest() { + this(new PromptRequestParams()); + } + + public PromptRequest(PromptRequestParams requestParams) { + super("session/prompt", requestParams); + } + + public static class PromptRequestParams extends Meta { + private String sessionId; + private List prompt; + + public PromptRequestParams(String sessionId, List prompt) { + this.sessionId = sessionId; + this.prompt = prompt; + } + + public PromptRequestParams() { + } + + // Getters and setters + public String getSessionId() { + return sessionId; + } + + public void setSessionId(String sessionId) { + this.sessionId = sessionId; + } + + public List getPrompt() { + return prompt; + } + + public void setPrompt(List prompt) { + this.prompt = prompt; + } + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/client/request/SetSessionModeRequest.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/client/request/SetSessionModeRequest.java new file mode 100644 index 000000000..4e6d1cccb --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/client/request/SetSessionModeRequest.java @@ -0,0 +1,40 @@ +package com.alibaba.acp.sdk.protocol.client.request; + +import com.alibaba.acp.sdk.protocol.jsonrpc.Meta; +import com.alibaba.acp.sdk.protocol.jsonrpc.Request; +import com.alibaba.fastjson2.annotation.JSONType; + +import static com.alibaba.acp.sdk.protocol.client.request.SetSessionModeRequest.SetSessionModeRequestParams; + +@JSONType(typeName = "session/set_mode") +public class SetSessionModeRequest extends Request { + public SetSessionModeRequest() { + this(new SetSessionModeRequestParams()); + } + + public SetSessionModeRequest(SetSessionModeRequestParams requestParams) { + super("session/set_mode", requestParams); + } + + public static class SetSessionModeRequestParams extends Meta { + private String sessionId; + private String modeId; + + // Getters and setters + public String getSessionId() { + return sessionId; + } + + public void setSessionId(String sessionId) { + this.sessionId = sessionId; + } + + public String getModeId() { + return modeId; + } + + public void setModeId(String modeId) { + this.modeId = modeId; + } + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/client/response/ReadTextFileResponse.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/client/response/ReadTextFileResponse.java new file mode 100644 index 000000000..401085f65 --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/client/response/ReadTextFileResponse.java @@ -0,0 +1,33 @@ +package com.alibaba.acp.sdk.protocol.client.response; + +import com.alibaba.acp.sdk.protocol.jsonrpc.Error; +import com.alibaba.acp.sdk.protocol.jsonrpc.Response; + +import static com.alibaba.acp.sdk.protocol.client.response.ReadTextFileResponse.ReadTextFileResponseResult; + +public class ReadTextFileResponse extends Response { + public ReadTextFileResponse() { + } + + public ReadTextFileResponse(Object id, ReadTextFileResponseResult result) { + super(id, result); + } + + public ReadTextFileResponse(Object id, Error error) { + super(id, error); + } + + public static class ReadTextFileResponseResult { + private String content; + + // Getters and setters + public String getContent() { + return content; + } + + public ReadTextFileResponseResult setContent(String content) { + this.content = content; + return this; + } + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/client/response/RequestPermissionResponse.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/client/response/RequestPermissionResponse.java new file mode 100644 index 000000000..278b1a6b9 --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/client/response/RequestPermissionResponse.java @@ -0,0 +1,40 @@ +package com.alibaba.acp.sdk.protocol.client.response; + +import com.alibaba.acp.sdk.protocol.domain.permission.RequestPermissionOutcome; +import com.alibaba.acp.sdk.protocol.jsonrpc.Error; +import com.alibaba.acp.sdk.protocol.jsonrpc.Response; + +import static com.alibaba.acp.sdk.protocol.client.response.RequestPermissionResponse.RequestPermissionResponseResult; + +public class RequestPermissionResponse extends Response { + public RequestPermissionResponse() { + } + + public RequestPermissionResponse(Object id, RequestPermissionResponseResult result) { + super(id, result); + } + + public RequestPermissionResponse(Object id, Error error) { + super(id, error); + } + + public static class RequestPermissionResponseResult { + private RequestPermissionOutcome outcome; + + public RequestPermissionResponseResult() { + } + + public RequestPermissionResponseResult(RequestPermissionOutcome outcome) { + this.outcome = outcome; + } + + // Getters and setters + public RequestPermissionOutcome getOutcome() { + return outcome; + } + + public void setOutcome(RequestPermissionOutcome outcome) { + this.outcome = outcome; + } + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/client/response/WriteTextFileResponse.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/client/response/WriteTextFileResponse.java new file mode 100644 index 000000000..c5cfb41b1 --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/client/response/WriteTextFileResponse.java @@ -0,0 +1,11 @@ +package com.alibaba.acp.sdk.protocol.client.response; + +import com.alibaba.acp.sdk.protocol.jsonrpc.Response; + +import static com.alibaba.acp.sdk.protocol.client.response.WriteTextFileResponse.WriteTextFileResponseResult; + +public class WriteTextFileResponse extends Response { + public static class WriteTextFileResponseResult { + // Empty result class as per schema + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/client/response/terminal/CreateTerminalResponse.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/client/response/terminal/CreateTerminalResponse.java new file mode 100644 index 000000000..3296ff6c4 --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/client/response/terminal/CreateTerminalResponse.java @@ -0,0 +1,20 @@ +package com.alibaba.acp.sdk.protocol.client.response.terminal; + +import com.alibaba.acp.sdk.protocol.jsonrpc.Response; + +import static com.alibaba.acp.sdk.protocol.client.response.terminal.CreateTerminalResponse.CreateTerminalResponseResult; + +public class CreateTerminalResponse extends Response { + public static class CreateTerminalResponseResult { + private String terminalId; + + // Getters and setters + public String getTerminalId() { + return terminalId; + } + + public void setTerminalId(String terminalId) { + this.terminalId = terminalId; + } + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/client/response/terminal/KillTerminalCommandResponse.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/client/response/terminal/KillTerminalCommandResponse.java new file mode 100644 index 000000000..22950fbc7 --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/client/response/terminal/KillTerminalCommandResponse.java @@ -0,0 +1,11 @@ +package com.alibaba.acp.sdk.protocol.client.response.terminal; + +import com.alibaba.acp.sdk.protocol.jsonrpc.Response; + +import static com.alibaba.acp.sdk.protocol.client.response.terminal.KillTerminalCommandResponse.KillTerminalCommandResponseResult; + +public class KillTerminalCommandResponse extends Response { + public static class KillTerminalCommandResponseResult { + // Empty result class as per schema + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/client/response/terminal/ReleaseTerminalResponse.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/client/response/terminal/ReleaseTerminalResponse.java new file mode 100644 index 000000000..224af4566 --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/client/response/terminal/ReleaseTerminalResponse.java @@ -0,0 +1,11 @@ +package com.alibaba.acp.sdk.protocol.client.response.terminal; + +import com.alibaba.acp.sdk.protocol.jsonrpc.Response; + +import static com.alibaba.acp.sdk.protocol.client.response.terminal.ReleaseTerminalResponse.ReleaseTerminalResponseResult; + +public class ReleaseTerminalResponse extends Response { + public static class ReleaseTerminalResponseResult { + // Empty result class as per schema + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/client/response/terminal/TerminalOutputResponse.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/client/response/terminal/TerminalOutputResponse.java new file mode 100644 index 000000000..56ac4cfa3 --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/client/response/terminal/TerminalOutputResponse.java @@ -0,0 +1,60 @@ +package com.alibaba.acp.sdk.protocol.client.response.terminal; + +import com.alibaba.acp.sdk.protocol.jsonrpc.Response; + +import static com.alibaba.acp.sdk.protocol.client.response.terminal.TerminalOutputResponse.TerminalOutputResponseResult; + +public class TerminalOutputResponse extends Response { + public static class TerminalOutputResponseResult { + private String output; + private Boolean truncated; + private TerminalExitStatus exitStatus; + + // Getters and setters + public String getOutput() { + return output; + } + + public void setOutput(String output) { + this.output = output; + } + + public Boolean getTruncated() { + return truncated; + } + + public void setTruncated(Boolean truncated) { + this.truncated = truncated; + } + + public TerminalExitStatus getExitStatus() { + return exitStatus; + } + + public void setExitStatus(TerminalExitStatus exitStatus) { + this.exitStatus = exitStatus; + } + + public static class TerminalExitStatus { + private Long exitCode; + private String signal; + + // Getters and setters + public Long getExitCode() { + return exitCode; + } + + public void setExitCode(Long exitCode) { + this.exitCode = exitCode; + } + + public String getSignal() { + return signal; + } + + public void setSignal(String signal) { + this.signal = signal; + } + } + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/client/response/terminal/WaitForTerminalExitResponse.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/client/response/terminal/WaitForTerminalExitResponse.java new file mode 100644 index 000000000..4af7f514d --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/client/response/terminal/WaitForTerminalExitResponse.java @@ -0,0 +1,29 @@ +package com.alibaba.acp.sdk.protocol.client.response.terminal; + +import com.alibaba.acp.sdk.protocol.jsonrpc.Response; + +import static com.alibaba.acp.sdk.protocol.client.response.terminal.WaitForTerminalExitResponse.WaitForTerminalExitResponseResult; + +public class WaitForTerminalExitResponse extends Response { + public static class WaitForTerminalExitResponseResult { + private Long exitCode; + private String signal; + + // Getters and setters + public Long getExitCode() { + return exitCode; + } + + public void setExitCode(Long exitCode) { + this.exitCode = exitCode; + } + + public String getSignal() { + return signal; + } + + public void setSignal(String signal) { + this.signal = signal; + } + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/agent/AgentCapabilities.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/agent/AgentCapabilities.java new file mode 100644 index 000000000..020b4f822 --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/agent/AgentCapabilities.java @@ -0,0 +1,189 @@ +package com.alibaba.acp.sdk.protocol.domain.agent; + +import com.alibaba.acp.sdk.protocol.jsonrpc.Meta; + +/** + * Agent Capabilities Class + * + * Describes functional capabilities supported by the agent, such as loading sessions, prompt capabilities, MCP, etc. + */ +public class AgentCapabilities extends Meta { + private Boolean loadSession; + private PromptCapabilities promptCapabilities; + private McpCapabilities mcp; + private Meta sessionCapabilities; + + /** + * Checks if loading sessions is supported + * + * @return True if loading sessions is supported, false otherwise + */ + public Boolean getLoadSession() { + return loadSession; + } + + /** + * Sets loading session support + * + * @param loadSession Whether loading sessions is supported + */ + public void setLoadSession(Boolean loadSession) { + this.loadSession = loadSession; + } + + /** + * Gets the prompt capabilities + * + * @return Prompt capabilities object + */ + public PromptCapabilities getPromptCapabilities() { + return promptCapabilities; + } + + /** + * Sets the prompt capabilities + * + * @param promptCapabilities Prompt capabilities object + */ + public void setPromptCapabilities(PromptCapabilities promptCapabilities) { + this.promptCapabilities = promptCapabilities; + } + + /** + * Gets the MCP capabilities + * + * @return MCP capabilities object + */ + public McpCapabilities getMcp() { + return mcp; + } + + /** + * Gets the session capabilities + * + * @return Session capabilities object + */ + public Meta getSessionCapabilities() { + return sessionCapabilities; + } + + /** + * Sets the session capabilities + * + * @param sessionCapabilities Session capabilities object + */ + public void setSessionCapabilities(Meta sessionCapabilities) { + this.sessionCapabilities = sessionCapabilities; + } + + /** + * Prompt Capabilities Class + * + * Describes the agent's support capabilities for different types of prompt content, such as images, audio, and embedded context. + */ + public static class PromptCapabilities extends Meta { + private Boolean image; + private Boolean audio; + private Boolean embeddedContext; + + /** + * Checks if images are supported + * + * @return True if images are supported, false otherwise + */ + public Boolean getImage() { + return image; + } + + /** + * Sets image support + * + * @param image Whether images are supported + */ + public void setImage(Boolean image) { + this.image = image; + } + + /** + * Checks if audio is supported + * + * @return True if audio is supported, false otherwise + */ + public Boolean getAudio() { + return audio; + } + + /** + * Sets audio support + * + * @param audio Whether audio is supported + */ + public void setAudio(Boolean audio) { + this.audio = audio; + } + + /** + * Checks if embedded context is supported + * + * @return True if embedded context is supported, false otherwise + */ + public Boolean getEmbeddedContext() { + return embeddedContext; + } + + /** + * Sets embedded context support + * + * @param embeddedContext Whether embedded context is supported + */ + public void setEmbeddedContext(Boolean embeddedContext) { + this.embeddedContext = embeddedContext; + } + } + + /** + * MCP Capabilities Class + * + * Describes the agent's support capabilities for MCP (Model Context Protocol). + */ + public static class McpCapabilities extends Meta { + private Boolean sse; + private Boolean mcp; + + /** + * Checks if SSE is supported + * + * @return True if SSE is supported, false otherwise + */ + public Boolean getSse() { + return sse; + } + + /** + * Sets SSE support + * + * @param sse Whether SSE is supported + */ + public void setSse(Boolean sse) { + this.sse = sse; + } + + /** + * Checks if MCP is supported + * + * @return True if MCP is supported, false otherwise + */ + public Boolean getMcp() { + return mcp; + } + + /** + * Sets MCP support + * + * @param mcp Whether MCP is supported + */ + public void setMcp(Boolean mcp) { + this.mcp = mcp; + } + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/agent/AgentInfo.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/agent/AgentInfo.java new file mode 100644 index 000000000..4fcb9a677 --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/agent/AgentInfo.java @@ -0,0 +1,68 @@ +package com.alibaba.acp.sdk.protocol.domain.agent; + +import com.alibaba.acp.sdk.protocol.jsonrpc.Meta; + +/** + * Agent Implementation Information Class + * + * Describes the agent's implementation information, such as name, title, and version. + */ +public class AgentInfo extends Meta { + private String name; + private String title; + private String version; + + /** + * Gets the implementation name + * + * @return Implementation name + */ + public String getName() { + return name; + } + + /** + * Sets the implementation name + * + * @param name Implementation name + */ + public void setName(String name) { + this.name = name; + } + + /** + * Gets the implementation title + * + * @return Implementation title + */ + public String getTitle() { + return title; + } + + /** + * Sets the implementation title + * + * @param title Implementation title + */ + public void setTitle(String title) { + this.title = title; + } + + /** + * Gets the implementation version + * + * @return Implementation version + */ + public String getVersion() { + return version; + } + + /** + * Sets the implementation version + * + * @param version Implementation version + */ + public void setVersion(String version) { + this.version = version; + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/agent/AuthMethod.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/agent/AuthMethod.java new file mode 100644 index 000000000..0f73cde7e --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/agent/AuthMethod.java @@ -0,0 +1,68 @@ +package com.alibaba.acp.sdk.protocol.domain.agent; + +import com.alibaba.acp.sdk.protocol.jsonrpc.Meta; + +/** + * Authentication Method Class + * + * Describes an available authentication method, including ID, name, and description. + */ +public class AuthMethod extends Meta { + private String id; + private String name; + private String description; + + /** + * Gets the authentication method ID + * + * @return Authentication method ID + */ + public String getId() { + return id; + } + + /** + * Sets the authentication method ID + * + * @param id Authentication method ID + */ + public void setId(String id) { + this.id = id; + } + + /** + * Gets the authentication method name + * + * @return Authentication method name + */ + public String getName() { + return name; + } + + /** + * Sets the authentication method name + * + * @param name Authentication method name + */ + public void setName(String name) { + this.name = name; + } + + /** + * Gets the authentication method description + * + * @return Authentication method description + */ + public String getDescription() { + return description; + } + + /** + * Sets the authentication method description + * + * @param description Authentication method description + */ + public void setDescription(String description) { + this.description = description; + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/client/ClientCapabilities.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/client/ClientCapabilities.java new file mode 100644 index 000000000..0c7c7be93 --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/client/ClientCapabilities.java @@ -0,0 +1,120 @@ +package com.alibaba.acp.sdk.protocol.domain.client; + +import com.alibaba.acp.sdk.protocol.jsonrpc.Meta; + +/** + * Client Capabilities Class + * + * Describes functional capabilities supported by the client, such as file system operations and terminal functionality. + */ +public class ClientCapabilities extends Meta { + private FileSystemCapability fs = new FileSystemCapability(); + private boolean terminal; + + /** + * Gets the file system capability + * + * @return File system capability object + */ + public FileSystemCapability getFs() { + return fs; + } + + /** + * Sets the file system capability + * + * @param fs File system capability object + * @return Current object instance, facilitating method chaining + */ + public ClientCapabilities setFs(FileSystemCapability fs) { + this.fs = fs; + return this; + } + + /** + * Checks if terminal functionality is supported + * + * @return True if terminal functionality is supported, false otherwise + */ + public boolean getTerminal() { + return terminal; + } + + /** + * Sets terminal functionality support + * + * @param terminal Whether terminal functionality is supported + * @return Current object instance, facilitating method chaining + */ + public ClientCapabilities setTerminal(Boolean terminal) { + this.terminal = terminal; + return this; + } + + /** + * File System Capability Class + * + * Describes the client's support capabilities for file system operations, such as reading and writing text files. + */ + public static class FileSystemCapability extends Meta { + private boolean readTextFile; + private boolean writeTextFile; + + /** + * Default constructor + */ + public FileSystemCapability() { + } + + /** + * Constructs a file system capability object with specified parameters + * + * @param readTextFile Whether reading text files is supported + * @param writeTextFile Whether writing text files is supported + */ + public FileSystemCapability(boolean readTextFile, boolean writeTextFile) { + this.readTextFile = readTextFile; + this.writeTextFile = writeTextFile; + } + + /** + * Checks if reading text files is supported + * + * @return True if reading text files is supported, false otherwise + */ + public boolean getReadTextFile() { + return readTextFile; + } + + /** + * Sets reading text files support + * + * @param readTextFile Whether reading text files is supported + * @return Current object instance, facilitating method chaining + */ + public FileSystemCapability setReadTextFile(boolean readTextFile) { + this.readTextFile = readTextFile; + return this; + } + + /** + * Checks if writing text files is supported + * + * @return True if writing text files is supported, false otherwise + */ + public boolean getWriteTextFile() { + return writeTextFile; + } + + /** + * Sets writing text files support + * + * @param writeTextFile Whether writing text files is supported + * @return Current object instance, facilitating method chaining + */ + public FileSystemCapability setWriteTextFile(boolean writeTextFile) { + this.writeTextFile = writeTextFile; + return this; + } + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/client/ClientInfo.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/client/ClientInfo.java new file mode 100644 index 000000000..b941f821e --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/client/ClientInfo.java @@ -0,0 +1,68 @@ +package com.alibaba.acp.sdk.protocol.domain.client; + +import com.alibaba.acp.sdk.protocol.jsonrpc.Meta; + +/** + * Client Information Class + * + * Describes basic client information such as name, title, and version. + */ +public class ClientInfo extends Meta { + private String name; + private String title; + private String version; + + /** + * Gets the client name + * + * @return Client name + */ + public String getName() { + return name; + } + + /** + * Sets the client name + * + * @param name Client name + */ + public void setName(String name) { + this.name = name; + } + + /** + * Gets the client title + * + * @return Client title + */ + public String getTitle() { + return title; + } + + /** + * Sets the client title + * + * @param title Client title + */ + public void setTitle(String title) { + this.title = title; + } + + /** + * Gets the client version + * + * @return Client version + */ + public String getVersion() { + return version; + } + + /** + * Sets the client version + * + * @param version Client version + */ + public void setVersion(String version) { + this.version = version; + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/content/Diff.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/content/Diff.java new file mode 100644 index 000000000..50afce520 --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/content/Diff.java @@ -0,0 +1,34 @@ +package com.alibaba.acp.sdk.protocol.domain.content; + +import com.alibaba.acp.sdk.protocol.jsonrpc.Meta; + +public class Diff extends Meta { + private String path; + private String newText; + private String oldText; + + // Getters and setters + public String getPath() { + return path; + } + + public void setPath(String path) { + this.path = path; + } + + public String getNewText() { + return newText; + } + + public void setNewText(String newText) { + this.newText = newText; + } + + public String getOldText() { + return oldText; + } + + public void setOldText(String oldText) { + this.oldText = oldText; + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/content/ToolCallContent.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/content/ToolCallContent.java new file mode 100644 index 000000000..8be92bfc6 --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/content/ToolCallContent.java @@ -0,0 +1,44 @@ +package com.alibaba.acp.sdk.protocol.domain.content; + +import com.alibaba.acp.sdk.protocol.domain.content.block.ContentBlock; +import com.alibaba.acp.sdk.protocol.jsonrpc.Meta; + +public class ToolCallContent extends Meta { + private String type; + private ContentBlock content; + private Diff diff; + private String terminalId; + + // Getters and setters + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public ContentBlock getContent() { + return content; + } + + public void setContent(ContentBlock content) { + this.content = content; + } + + public Diff getDiff() { + return diff; + } + + public void setDiff(Diff diff) { + this.diff = diff; + } + + public String getTerminalId() { + return terminalId; + } + + public void setTerminalId(String terminalId) { + this.terminalId = terminalId; + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/content/block/Annotations.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/content/block/Annotations.java new file mode 100644 index 000000000..a96e755cc --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/content/block/Annotations.java @@ -0,0 +1,53 @@ +package com.alibaba.acp.sdk.protocol.domain.content.block; + +import java.util.List; + +import com.alibaba.acp.sdk.protocol.jsonrpc.Meta; + +public class Annotations extends Meta { + private List audience; + private Double priority; + private String lastModified; + + // Getters and setters + public List getAudience() { + return audience; + } + + public void setAudience(List audience) { + this.audience = audience; + } + + public Double getPriority() { + return priority; + } + + public void setPriority(Double priority) { + this.priority = priority; + } + + public String getLastModified() { + return lastModified; + } + + public void setLastModified(String lastModified) { + this.lastModified = lastModified; + } + + // Inner class for Role + public static class Role { + private String role; + + public Role(String role) { + this.role = role; + } + + public String getRole() { + return role; + } + + public void setRole(String role) { + this.role = role; + } + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/content/block/AudioContent.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/content/block/AudioContent.java new file mode 100644 index 000000000..51cf1c798 --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/content/block/AudioContent.java @@ -0,0 +1,40 @@ +package com.alibaba.acp.sdk.protocol.domain.content.block; + +import com.alibaba.fastjson2.annotation.JSONType; + +@JSONType(typeKey = "type", typeName = "audio") +public class AudioContent extends ContentBlock { + private String data; + private String mimeType; + private Annotations annotations; + + public AudioContent() { + super(); + this.type = "audio"; + } + + // Getters and setters + public String getData() { + return data; + } + + public void setData(String data) { + this.data = data; + } + + public String getMimeType() { + return mimeType; + } + + public void setMimeType(String mimeType) { + this.mimeType = mimeType; + } + + public Annotations getAnnotations() { + return annotations; + } + + public void setAnnotations(Annotations annotations) { + this.annotations = annotations; + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/content/block/ContentBlock.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/content/block/ContentBlock.java new file mode 100644 index 000000000..24e1d79da --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/content/block/ContentBlock.java @@ -0,0 +1,18 @@ +package com.alibaba.acp.sdk.protocol.domain.content.block; + +import com.alibaba.acp.sdk.protocol.domain.content.embedded.EmbeddedResource; +import com.alibaba.acp.sdk.protocol.jsonrpc.Meta; +import com.alibaba.fastjson2.annotation.JSONType; + +@JSONType(typeKey = "type", typeName = "ContentBlock", seeAlso = {TextContent.class, ImageContent.class, AudioContent.class, ResourceLink.class, EmbeddedResource.class}) +public class ContentBlock extends Meta { + protected String type; + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/content/block/ImageContent.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/content/block/ImageContent.java new file mode 100644 index 000000000..0558c98b6 --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/content/block/ImageContent.java @@ -0,0 +1,49 @@ +package com.alibaba.acp.sdk.protocol.domain.content.block; + +import com.alibaba.fastjson2.annotation.JSONType; + +@JSONType(typeKey = "type", typeName = "image") +public class ImageContent extends ContentBlock { + private String data; + private String mimeType; + private String uri; + private Annotations annotations; + + public ImageContent() { + super(); + this.type = "image"; + } + + // Getters and setters + public String getData() { + return data; + } + + public void setData(String data) { + this.data = data; + } + + public String getMimeType() { + return mimeType; + } + + public void setMimeType(String mimeType) { + this.mimeType = mimeType; + } + + public String getUri() { + return uri; + } + + public void setUri(String uri) { + this.uri = uri; + } + + public Annotations getAnnotations() { + return annotations; + } + + public void setAnnotations(Annotations annotations) { + this.annotations = annotations; + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/content/block/ResourceLink.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/content/block/ResourceLink.java new file mode 100644 index 000000000..48b207929 --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/content/block/ResourceLink.java @@ -0,0 +1,76 @@ +package com.alibaba.acp.sdk.protocol.domain.content.block; + +import com.alibaba.fastjson2.annotation.JSONType; + +@JSONType(typeKey = "type", typeName = "resource_link") +public class ResourceLink extends ContentBlock { + private String name; + private String title; + private String uri; + private String description; + private String mimeType; + private Annotations annotations; + private Long size; + + public ResourceLink() { + super(); + this.type = "resource_link"; + } + + // Getters and setters + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getUri() { + return uri; + } + + public void setUri(String uri) { + this.uri = uri; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getMimeType() { + return mimeType; + } + + public void setMimeType(String mimeType) { + this.mimeType = mimeType; + } + + public Annotations getAnnotations() { + return annotations; + } + + public void setAnnotations(Annotations annotations) { + this.annotations = annotations; + } + + public Long getSize() { + return size; + } + + public void setSize(Long size) { + this.size = size; + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/content/block/TextContent.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/content/block/TextContent.java new file mode 100644 index 000000000..508c75cb4 --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/content/block/TextContent.java @@ -0,0 +1,36 @@ +package com.alibaba.acp.sdk.protocol.domain.content.block; + +import com.alibaba.fastjson2.annotation.JSONType; + +@JSONType(typeKey = "type", typeName = "text") +public class TextContent extends ContentBlock { + private String text; + private Annotations annotations; + + public TextContent() { + super(); + this.type = "text"; + } + + public TextContent(String text) { + this(); + this.text = text; + } + + // Getters and setters + public String getText() { + return text; + } + + public void setText(String text) { + this.text = text; + } + + public Annotations getAnnotations() { + return annotations; + } + + public void setAnnotations(Annotations annotations) { + this.annotations = annotations; + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/content/embedded/BlobResourceContents.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/content/embedded/BlobResourceContents.java new file mode 100644 index 000000000..9c9499fe7 --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/content/embedded/BlobResourceContents.java @@ -0,0 +1,13 @@ +package com.alibaba.acp.sdk.protocol.domain.content.embedded; + +public class BlobResourceContents extends ResourceContent { + private String blob; + + public String getBlob() { + return blob; + } + + public void setBlob(String blob) { + this.blob = blob; + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/content/embedded/EmbeddedResource.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/content/embedded/EmbeddedResource.java new file mode 100644 index 000000000..dadf54b60 --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/content/embedded/EmbeddedResource.java @@ -0,0 +1,32 @@ +package com.alibaba.acp.sdk.protocol.domain.content.embedded; + +import com.alibaba.acp.sdk.protocol.domain.content.block.Annotations; +import com.alibaba.acp.sdk.protocol.domain.content.block.ContentBlock; +import com.alibaba.fastjson2.annotation.JSONType; + +@JSONType(typeKey = "type", typeName = "resource") +public class EmbeddedResource extends ContentBlock { + private ResourceContent resource; // This could be TextResourceContents or BlobResourceContents + private Annotations annotations; + + public EmbeddedResource() { + super(); + this.type = "resource"; + } + + public ResourceContent getResource() { + return resource; + } + + public void setResource(ResourceContent resource) { + this.resource = resource; + } + + public Annotations getAnnotations() { + return annotations; + } + + public void setAnnotations(Annotations annotations) { + this.annotations = annotations; + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/content/embedded/ResourceContent.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/content/embedded/ResourceContent.java new file mode 100644 index 000000000..35792515b --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/content/embedded/ResourceContent.java @@ -0,0 +1,26 @@ +package com.alibaba.acp.sdk.protocol.domain.content.embedded; + +import com.alibaba.acp.sdk.protocol.jsonrpc.Meta; +import com.alibaba.fastjson2.annotation.JSONType; + +@JSONType(deserializer = ResourceContentDeserializer.class) +public class ResourceContent extends Meta { + protected String mimeType; + protected String uri; + + public String getMimeType() { + return mimeType; + } + + public void setMimeType(String mimeType) { + this.mimeType = mimeType; + } + + public String getUri() { + return uri; + } + + public void setUri(String uri) { + this.uri = uri; + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/content/embedded/ResourceContentDeserializer.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/content/embedded/ResourceContentDeserializer.java new file mode 100644 index 000000000..12a41df93 --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/content/embedded/ResourceContentDeserializer.java @@ -0,0 +1,24 @@ +package com.alibaba.acp.sdk.protocol.domain.content.embedded; + +import com.alibaba.fastjson2.JSONObject; +import com.alibaba.fastjson2.JSONReader; +import com.alibaba.fastjson2.reader.ObjectReader; + +import java.lang.reflect.Type; + +public class ResourceContentDeserializer implements ObjectReader { + @Override + public ResourceContent readObject(JSONReader jsonReader, Type fieldType, Object fieldName, long features) { + if (jsonReader == null || jsonReader.nextIfNull()) { + return null; + } + JSONObject jsonObject = jsonReader.readJSONObject(); + if (jsonObject.containsKey("blob")) { + return jsonObject.to(BlobResourceContents.class); + } else if (jsonObject.containsKey("text")) { + return jsonObject.to(TextResourceContents.class); + } else { + return jsonObject.to(ResourceContent.class); + } + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/content/embedded/TextResourceContents.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/content/embedded/TextResourceContents.java new file mode 100644 index 000000000..9b6d720b5 --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/content/embedded/TextResourceContents.java @@ -0,0 +1,13 @@ +package com.alibaba.acp.sdk.protocol.domain.content.embedded; + +public class TextResourceContents extends ResourceContent { + private String text; + + public String getText() { + return text; + } + + public void setText(String text) { + this.text = text; + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/mcp/HttpHeader.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/mcp/HttpHeader.java new file mode 100644 index 000000000..6e96de132 --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/mcp/HttpHeader.java @@ -0,0 +1,25 @@ +package com.alibaba.acp.sdk.protocol.domain.mcp; + +import com.alibaba.acp.sdk.protocol.jsonrpc.Meta; + +public class HttpHeader extends Meta { + private String name; + private String value; + + // Getters and setters + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/mcp/McpServer.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/mcp/McpServer.java new file mode 100644 index 000000000..1a014c4a9 --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/mcp/McpServer.java @@ -0,0 +1,75 @@ +package com.alibaba.acp.sdk.protocol.domain.mcp; + +import java.util.List; + +import com.alibaba.acp.sdk.protocol.domain.terminal.EnvVariable; +import com.alibaba.acp.sdk.protocol.jsonrpc.Meta; + +public class McpServer extends Meta { + private String type; + private String name; + private String command; + private List args; + private List env; + + // Stdio-specific fields + private List headers; + private String url; + + // Getters and setters + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getCommand() { + return command; + } + + public void setCommand(String command) { + this.command = command; + } + + public List getArgs() { + return args; + } + + public void setArgs(List args) { + this.args = args; + } + + public List getEnv() { + return env; + } + + public void setEnv(List env) { + this.env = env; + } + + public List getHeaders() { + return headers; + } + + public void setHeaders(List headers) { + this.headers = headers; + } + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/permission/PermissionOption.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/permission/PermissionOption.java new file mode 100644 index 000000000..f1f7e66ca --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/permission/PermissionOption.java @@ -0,0 +1,34 @@ +package com.alibaba.acp.sdk.protocol.domain.permission; + +import com.alibaba.acp.sdk.protocol.jsonrpc.Meta; + +public class PermissionOption extends Meta { + private String optionId; + private String name; + private PermissionOptionKind kind; + + // Getters and setters + public String getOptionId() { + return optionId; + } + + public void setOptionId(String optionId) { + this.optionId = optionId; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public PermissionOptionKind getKind() { + return kind; + } + + public void setKind(PermissionOptionKind kind) { + this.kind = kind; + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/permission/PermissionOptionKind.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/permission/PermissionOptionKind.java new file mode 100644 index 000000000..5fe02707b --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/permission/PermissionOptionKind.java @@ -0,0 +1,17 @@ +package com.alibaba.acp.sdk.protocol.domain.permission; + +import com.alibaba.fastjson2.annotation.JSONField; + +public enum PermissionOptionKind { + @JSONField(name = "allow_once", label = "Allow this operation only this time.") + ALLOW_ONCE, + + @JSONField(name = "allow_always", label = "Allow this operation and remember the choice.") + ALLOW_ALWAYS, + + @JSONField(name = "reject_once", label = "Reject this operation only this time.") + REJECT_ONCE, + + @JSONField(name = "reject_always", label = "Reject this operation and remember the choice.") + REJECT_ALWAYS; +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/permission/PermissionOutcomeKind.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/permission/PermissionOutcomeKind.java new file mode 100644 index 000000000..03177612c --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/permission/PermissionOutcomeKind.java @@ -0,0 +1,10 @@ +package com.alibaba.acp.sdk.protocol.domain.permission; + +import com.alibaba.fastjson2.annotation.JSONField; + +public enum PermissionOutcomeKind { + @JSONField(name = "selected", label = "The user selected an option.") + SELECTED, + @JSONField(name = "cancelled", label = "The user cancelled the prompt.") + CANCELLED +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/permission/RequestPermissionOutcome.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/permission/RequestPermissionOutcome.java new file mode 100644 index 000000000..f83d23e65 --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/permission/RequestPermissionOutcome.java @@ -0,0 +1,34 @@ +package com.alibaba.acp.sdk.protocol.domain.permission; + +// Inner class for RequestPermissionOutcome +public class RequestPermissionOutcome { + private PermissionOutcomeKind outcome; + private String optionId; + + public RequestPermissionOutcome() { + } + + public RequestPermissionOutcome(PermissionOutcomeKind outcome, String optionId) { + this.outcome = outcome; + this.optionId = optionId; + } + + // Getters and setters + public PermissionOutcomeKind getOutcome() { + return outcome; + } + + public RequestPermissionOutcome setOutcome(PermissionOutcomeKind outcome) { + this.outcome = outcome; + return this; + } + + public String getOptionId() { + return optionId; + } + + public RequestPermissionOutcome setOptionId(String optionId) { + this.optionId = optionId; + return this; + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/plan/Plan.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/plan/Plan.java new file mode 100644 index 000000000..086b04d42 --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/plan/Plan.java @@ -0,0 +1,18 @@ +package com.alibaba.acp.sdk.protocol.domain.plan; + +import java.util.List; + +import com.alibaba.acp.sdk.protocol.jsonrpc.Meta; + +public class Plan extends Meta { + private List entries; + + // Getters and setters + public List getEntries() { + return entries; + } + + public void setEntries(List entries) { + this.entries = entries; + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/plan/PlanEntry.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/plan/PlanEntry.java new file mode 100644 index 000000000..2d87e2819 --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/plan/PlanEntry.java @@ -0,0 +1,34 @@ +package com.alibaba.acp.sdk.protocol.domain.plan; + +import com.alibaba.acp.sdk.protocol.jsonrpc.Meta; + +public class PlanEntry extends Meta { + private String content; + private PlanEntryPriority priority; + private PlanEntryStatus status; + + // Getters and setters + public String getContent() { + return content; + } + + public void setContent(String content) { + this.content = content; + } + + public PlanEntryPriority getPriority() { + return priority; + } + + public void setPriority(PlanEntryPriority priority) { + this.priority = priority; + } + + public PlanEntryStatus getStatus() { + return status; + } + + public void setStatus(PlanEntryStatus status) { + this.status = status; + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/plan/PlanEntryPriority.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/plan/PlanEntryPriority.java new file mode 100644 index 000000000..28ad72feb --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/plan/PlanEntryPriority.java @@ -0,0 +1,14 @@ +package com.alibaba.acp.sdk.protocol.domain.plan; + +import com.alibaba.fastjson2.annotation.JSONField; + +public enum PlanEntryPriority { + @JSONField(name = "high", label = "High priority task - critical to the overall goal.") + HIGH, + + @JSONField(name = "medium", label = "Medium priority task - important but not critical.") + MEDIUM, + + @JSONField(name = "low", label = "Low priority task - nice to have but not essential.") + LOW; +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/plan/PlanEntryStatus.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/plan/PlanEntryStatus.java new file mode 100644 index 000000000..8fb88b9c1 --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/plan/PlanEntryStatus.java @@ -0,0 +1,14 @@ +package com.alibaba.acp.sdk.protocol.domain.plan; + +import com.alibaba.fastjson2.annotation.JSONField; + +public enum PlanEntryStatus { + @JSONField(name = "pending", label = "The task has not started yet.") + PENDING, + + @JSONField(name = "in_progress", label = "The task is currently being worked on.") + IN_PROGRESS, + + @JSONField(name = "completed", label = "The task has been successfully completed.") + COMPLETED; +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/session/SessionMode.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/session/SessionMode.java new file mode 100644 index 000000000..eaa9a18cf --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/session/SessionMode.java @@ -0,0 +1,34 @@ +package com.alibaba.acp.sdk.protocol.domain.session; + +import com.alibaba.acp.sdk.protocol.jsonrpc.Meta; + +public class SessionMode extends Meta { + private String id; + private String name; + private String description; + + // Getters and setters + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/session/SessionModeState.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/session/SessionModeState.java new file mode 100644 index 000000000..007975651 --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/session/SessionModeState.java @@ -0,0 +1,27 @@ +package com.alibaba.acp.sdk.protocol.domain.session; + +import java.util.List; + +import com.alibaba.acp.sdk.protocol.jsonrpc.Meta; + +public class SessionModeState extends Meta { + private String currentModeId; + private List availableModes; + + // Getters and setters + public String getCurrentModeId() { + return currentModeId; + } + + public void setCurrentModeId(String currentModeId) { + this.currentModeId = currentModeId; + } + + public List getAvailableModes() { + return availableModes; + } + + public void setAvailableModes(List availableModes) { + this.availableModes = availableModes; + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/session/StopReason.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/session/StopReason.java new file mode 100644 index 000000000..ad2f4d23a --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/session/StopReason.java @@ -0,0 +1,20 @@ +package com.alibaba.acp.sdk.protocol.domain.session; + +import com.alibaba.fastjson2.annotation.JSONField; + +public enum StopReason { + @JSONField(name = "end_turn", label = "The turn ended successfully.") + END_TURN, + + @JSONField(name = "max_tokens", label = "The turn ended because the agent reached the maximum number of tokens.") + MAX_TOKENS, + + @JSONField(name = "max_turn_requests", label = "The turn ended because the agent reached the maximum number of allowed\nagent requests between user turns.") + MAX_TURN_REQUESTS, + + @JSONField(name = "refusal", label = "The turn ended because the agent refused to continue. The user prompt\nand everything that comes after it won't be included in the next\nprompt, so this should be reflected in the UI.") + REFUSAL, + + @JSONField(name = "cancelled", label = "The turn was cancelled by the client via `session/cancel`.\n\nThis stop reason MUST be returned when the client sends a `session/cancel`\nnotification, even if the cancellation causes exceptions in underlying operations.\nAgents should catch these exceptions and return this semantically meaningful\nresponse to confirm successful cancellation.") + CANCELLED; +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/session/update/AgentMessageChunkSessionUpdate.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/session/update/AgentMessageChunkSessionUpdate.java new file mode 100644 index 000000000..1a53c3f84 --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/session/update/AgentMessageChunkSessionUpdate.java @@ -0,0 +1,21 @@ +package com.alibaba.acp.sdk.protocol.domain.session.update; + +import com.alibaba.acp.sdk.protocol.domain.content.block.ContentBlock; +import com.alibaba.fastjson2.annotation.JSONType; + +@JSONType(typeName = "agent_message_chunk") +public class AgentMessageChunkSessionUpdate extends SessionUpdate { + private ContentBlock content; + + public AgentMessageChunkSessionUpdate() { + this.setSessionUpdate("agent_message_chunk"); + } + + public ContentBlock getContent() { + return content; + } + + public void setContent(ContentBlock content) { + this.content = content; + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/session/update/AvailableCommand.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/session/update/AvailableCommand.java new file mode 100644 index 000000000..d2af7af84 --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/session/update/AvailableCommand.java @@ -0,0 +1,34 @@ +package com.alibaba.acp.sdk.protocol.domain.session.update; + +import com.alibaba.acp.sdk.protocol.jsonrpc.Meta; + +public class AvailableCommand extends Meta { + private String name; + private String description; + private UnstructuredCommandInput input; + + // Getters and setters + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public UnstructuredCommandInput getInput() { + return input; + } + + public void setInput(UnstructuredCommandInput input) { + this.input = input; + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/session/update/AvailableCommandsUpdateSessionUpdate.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/session/update/AvailableCommandsUpdateSessionUpdate.java new file mode 100644 index 000000000..dba3d12b2 --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/session/update/AvailableCommandsUpdateSessionUpdate.java @@ -0,0 +1,22 @@ +package com.alibaba.acp.sdk.protocol.domain.session.update; + +import java.util.List; + +import com.alibaba.fastjson2.annotation.JSONType; + +@JSONType(typeName = "available_commands_update") +public class AvailableCommandsUpdateSessionUpdate extends SessionUpdate { + private List availableCommands; + + public AvailableCommandsUpdateSessionUpdate() { + this.setSessionUpdate("available_commands_update"); + } + + public List getAvailableCommands() { + return availableCommands; + } + + public void setAvailableCommands(List availableCommands) { + this.availableCommands = availableCommands; + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/session/update/CurrentModeUpdateSessionUpdate.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/session/update/CurrentModeUpdateSessionUpdate.java new file mode 100644 index 000000000..2f42727dd --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/session/update/CurrentModeUpdateSessionUpdate.java @@ -0,0 +1,20 @@ +package com.alibaba.acp.sdk.protocol.domain.session.update; + +import com.alibaba.fastjson2.annotation.JSONType; + +@JSONType(typeName = "current_mode_update") +public class CurrentModeUpdateSessionUpdate extends SessionUpdate { + private String currentModeId; + + public CurrentModeUpdateSessionUpdate() { + this.setSessionUpdate("current_mode_update"); + } + + public String getCurrentModeId() { + return currentModeId; + } + + public void setCurrentModeId(String currentModeId) { + this.currentModeId = currentModeId; + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/session/update/PlanSessionUpdate.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/session/update/PlanSessionUpdate.java new file mode 100644 index 000000000..b9ed3c305 --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/session/update/PlanSessionUpdate.java @@ -0,0 +1,23 @@ +package com.alibaba.acp.sdk.protocol.domain.session.update; + +import java.util.List; + +import com.alibaba.acp.sdk.protocol.domain.plan.PlanEntry; +import com.alibaba.fastjson2.annotation.JSONType; + +@JSONType(typeName = "plan") +public class PlanSessionUpdate extends SessionUpdate { + private List entries; + + public PlanSessionUpdate() { + this.setSessionUpdate("plan"); + } + + public List getEntries() { + return entries; + } + + public void setEntries(List entries) { + this.entries = entries; + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/session/update/SessionUpdate.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/session/update/SessionUpdate.java new file mode 100644 index 000000000..d9aa7bb14 --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/session/update/SessionUpdate.java @@ -0,0 +1,23 @@ +package com.alibaba.acp.sdk.protocol.domain.session.update; + +import com.alibaba.acp.sdk.protocol.jsonrpc.Meta; +import com.alibaba.fastjson2.annotation.JSONType; + +@JSONType(typeKey = "sessionUpdate", typeName = "session_update", + seeAlso = {AgentMessageChunkSessionUpdate.class, + AvailableCommandsUpdateSessionUpdate.class, + CurrentModeUpdateSessionUpdate.class, + PlanSessionUpdate.class, + ToolCallSessionUpdate.class, + ToolCallUpdateSessionUpdate.class}) +public class SessionUpdate extends Meta { + String sessionUpdate; + + public String getSessionUpdate() { + return sessionUpdate; + } + + public void setSessionUpdate(String sessionUpdate) { + this.sessionUpdate = sessionUpdate; + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/session/update/ToolCallSessionUpdate.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/session/update/ToolCallSessionUpdate.java new file mode 100644 index 000000000..777f0cb65 --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/session/update/ToolCallSessionUpdate.java @@ -0,0 +1,11 @@ +package com.alibaba.acp.sdk.protocol.domain.session.update; + +import com.alibaba.acp.sdk.protocol.domain.tool.ToolCallUpdate; +import com.alibaba.fastjson2.annotation.JSONField; +import com.alibaba.fastjson2.annotation.JSONType; + +@JSONType(typeName = "tool_call") +public class ToolCallSessionUpdate extends SessionUpdate { + @JSONField(unwrapped = true) + ToolCallUpdate toolCallUpdate; +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/session/update/ToolCallUpdateSessionUpdate.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/session/update/ToolCallUpdateSessionUpdate.java new file mode 100644 index 000000000..84bee2165 --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/session/update/ToolCallUpdateSessionUpdate.java @@ -0,0 +1,11 @@ +package com.alibaba.acp.sdk.protocol.domain.session.update; + +import com.alibaba.acp.sdk.protocol.domain.tool.ToolCallUpdate; +import com.alibaba.fastjson2.annotation.JSONField; +import com.alibaba.fastjson2.annotation.JSONType; + +@JSONType(typeName = "tool_call_update") +public class ToolCallUpdateSessionUpdate extends SessionUpdate { + @JSONField(unwrapped = true) + ToolCallUpdate toolCallUpdate; +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/session/update/UnstructuredCommandInput.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/session/update/UnstructuredCommandInput.java new file mode 100644 index 000000000..4a896cf53 --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/session/update/UnstructuredCommandInput.java @@ -0,0 +1,16 @@ +package com.alibaba.acp.sdk.protocol.domain.session.update; + +import com.alibaba.acp.sdk.protocol.jsonrpc.Meta; + +public class UnstructuredCommandInput extends Meta { + private String hint; + + // Getters and setters + public String getHint() { + return hint; + } + + public void setHint(String hint) { + this.hint = hint; + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/terminal/EnvVariable.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/terminal/EnvVariable.java new file mode 100644 index 000000000..8f85516a5 --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/terminal/EnvVariable.java @@ -0,0 +1,25 @@ +package com.alibaba.acp.sdk.protocol.domain.terminal; + +import com.alibaba.acp.sdk.protocol.jsonrpc.Meta; + +public class EnvVariable extends Meta { + private String name; + private String value; + + // Getters and setters + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/tool/ToolCallLocation.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/tool/ToolCallLocation.java new file mode 100644 index 000000000..5b0f72812 --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/tool/ToolCallLocation.java @@ -0,0 +1,25 @@ +package com.alibaba.acp.sdk.protocol.domain.tool; + +import com.alibaba.acp.sdk.protocol.jsonrpc.Meta; + +public class ToolCallLocation extends Meta { + private String path; + private Integer line; + + // Getters and setters + public String getPath() { + return path; + } + + public void setPath(String path) { + this.path = path; + } + + public Integer getLine() { + return line; + } + + public void setLine(Integer line) { + this.line = line; + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/tool/ToolCallStatus.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/tool/ToolCallStatus.java new file mode 100644 index 000000000..eb0351027 --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/tool/ToolCallStatus.java @@ -0,0 +1,17 @@ +package com.alibaba.acp.sdk.protocol.domain.tool; + +import com.alibaba.fastjson2.annotation.JSONField; + +public enum ToolCallStatus { + @JSONField(name = "pending", label = "The tool call hasn't started running yet because the input is either\nstreaming or we're awaiting approval.") + PENDING, + + @JSONField(name = "in_progress", label = "The tool call is currently running.") + IN_PROGRESS, + + @JSONField(name = "completed", label = "The tool call completed successfully.") + COMPLETED, + + @JSONField(name = "failed", label = "The tool call failed with an error.") + FAILED; +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/tool/ToolCallUpdate.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/tool/ToolCallUpdate.java new file mode 100644 index 000000000..4bcecde68 --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/tool/ToolCallUpdate.java @@ -0,0 +1,82 @@ +package com.alibaba.acp.sdk.protocol.domain.tool; + +import java.util.List; + +import com.alibaba.acp.sdk.protocol.domain.content.ToolCallContent; +import com.alibaba.acp.sdk.protocol.jsonrpc.Meta; + +public class ToolCallUpdate extends Meta { + private String toolCallId; + private String title; + private ToolKind kind; + private ToolCallStatus status; + private Object rawInput; + private Object rawOutput; + private List locations; + private List content; + + // Getters and setters + public String getToolCallId() { + return toolCallId; + } + + public void setToolCallId(String toolCallId) { + this.toolCallId = toolCallId; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public ToolKind getKind() { + return kind; + } + + public void setKind(ToolKind kind) { + this.kind = kind; + } + + public ToolCallStatus getStatus() { + return status; + } + + public void setStatus(ToolCallStatus status) { + this.status = status; + } + + public Object getRawInput() { + return rawInput; + } + + public void setRawInput(Object rawInput) { + this.rawInput = rawInput; + } + + public Object getRawOutput() { + return rawOutput; + } + + public void setRawOutput(Object rawOutput) { + this.rawOutput = rawOutput; + } + + public List getLocations() { + return locations; + } + + public void setLocations(List locations) { + this.locations = locations; + } + + public List getContent() { + return content; + } + + public void setContent(List content) { + this.content = content; + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/tool/ToolKind.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/tool/ToolKind.java new file mode 100644 index 000000000..25cfb048e --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/domain/tool/ToolKind.java @@ -0,0 +1,35 @@ +package com.alibaba.acp.sdk.protocol.domain.tool; + +import com.alibaba.fastjson2.annotation.JSONField; + +public enum ToolKind { + @JSONField(name = "read", label = "Reading files or data.") + READ, + + @JSONField(name = "edit", label = "Modifying files or content.") + EDIT, + + @JSONField(name = "delete", label = "Removing files or data.") + DELETE, + + @JSONField(name = "move", label = "Moving or renaming files.") + MOVE, + + @JSONField(name = "search", label = "Searching for information.") + SEARCH, + + @JSONField(name = "execute", label = "Running commands or code.") + EXECUTE, + + @JSONField(name = "think", label = "Internal reasoning or planning.") + THINK, + + @JSONField(name = "fetch", label = "Retrieving external data.") + FETCH, + + @JSONField(name = "switch_mode", label = "Switching the current session mode.") + SWITCH_MODE, + + @JSONField(name = "other", label = "Other tool types (default).") + OTHER; +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/jsonrpc/Error.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/jsonrpc/Error.java new file mode 100644 index 000000000..a1a40c5cd --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/jsonrpc/Error.java @@ -0,0 +1,181 @@ +package com.alibaba.acp.sdk.protocol.jsonrpc; + +import com.alibaba.fastjson2.annotation.JSONField; + +/** + * Represents an error in the JSON-RPC protocol. + * + * This class encapsulates error information including code, message, and optional data + * that can be returned to clients when errors occur during RPC processing. + * + * @author SkyFire + * @version 0.0.1 + */ +public class Error { + private int code; + private String message; + private Object data; + + /** + * Constructs a new Error with the specified code, message, and data. + * + * @param code The error code + * @param message The error message + * @param data Additional error data + */ + public Error(int code, String message, Object data) { + this.code = code; + this.message = message; + this.data = data; + } + + /** + * Constructs a new Error with default values. + */ + public Error() { + } + + /** + * Gets the error code. + * + * @return The error code + */ + public int getCode() { + return code; + } + + /** + * Sets the error code. + * + * @param code The error code + * @return This error instance for method chaining + */ + public Error setCode(int code) { + this.code = code; + return this; + } + + /** + * Gets the error message. + * + * @return The error message + */ + public String getMessage() { + return message; + } + + /** + * Sets the error message. + * + * @param message The error message + * @return This error instance for method chaining + */ + public Error setMessage(String message) { + this.message = message; + return this; + } + + /** + * Gets the error data. + * + * @return The error data + */ + public Object getData() { + return data; + } + + /** + * Sets the error data. + * + * @param data The error data + * @return This error instance for method chaining + */ + public Error setData(Object data) { + this.data = data; + return this; + } + + /** + * Enum representing standard error codes in the JSON-RPC protocol. + * + * These codes follow the JSON-RPC 2.0 specification with additional custom codes + * for ACP-specific error conditions. + */ + public enum ErrorCode { + /** + * Parse error: Invalid JSON was received by the server. + * An error occurred on the server while parsing the JSON text. + */ + PARSE_ERROR(-32700, "**Parse error**: Invalid JSON was received by the server.\nAn error occurred on the server while parsing the JSON text."), + + /** + * Invalid request: The JSON sent is not a valid Request object. + */ + INVALID_REQUEST(-32600, "**Invalid request**: The JSON sent is not a valid Request object."), + + /** + * Method not found: The method does not exist or is not available. + */ + METHOD_NOT_FOUND(-32601, "**Method not found**: The method does not exist or is not available."), + + /** + * Invalid params: Invalid method parameter(s). + */ + INVALID_PARAMS(-32602, "**Invalid params**: Invalid method parameter(s)."), + + /** + * Internal error: Internal JSON-RPC error. + * Reserved for implementation-defined server errors. + */ + INTERNAL_ERROR(-32603, "**Internal error**: Internal JSON-RPC error.\nReserved for implementation-defined server errors."), + + /** + * Authentication required: Authentication is required before this operation can be performed. + */ + AUTHENTICATION_REQUIRED(-32000, "**Authentication required**: Authentication is required before this operation can be performed."), + + /** + * Resource not found: A given resource, such as a file, was not found. + */ + RESOURCE_NOT_FOUND(-32002, "**Resource not found**: A given resource, such as a file, was not found."), + + /** + * Other undefined error code. + */ + OTHER_UNDEFINED_ERROR(-32004, "Other undefined error code."); + + private final int code; + private final String description; + + /** + * Constructs a new ErrorCode with the specified code and description. + * + * @param code The error code + * @param description The error description + */ + ErrorCode(int code, String description) { + this.code = code; + this.description = description; + } + + /** + * Gets the error code. + * + * @return The error code + */ + @JSONField + public int getCode() { + return code; + } + + /** + * Gets the error description. + * + * @return The error description + */ + @JSONField(deserialize = false) + public String getDescription() { + return description; + } + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/jsonrpc/ExtNotification.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/jsonrpc/ExtNotification.java new file mode 100644 index 000000000..fb029f8ba --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/jsonrpc/ExtNotification.java @@ -0,0 +1,4 @@ +package com.alibaba.acp.sdk.protocol.jsonrpc; + +public class ExtNotification extends MethodMessage { +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/jsonrpc/ExtRequest.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/jsonrpc/ExtRequest.java new file mode 100644 index 000000000..32a3d915a --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/jsonrpc/ExtRequest.java @@ -0,0 +1,4 @@ +package com.alibaba.acp.sdk.protocol.jsonrpc; + +public class ExtRequest extends Request { +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/jsonrpc/ExtResponse.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/jsonrpc/ExtResponse.java new file mode 100644 index 000000000..30174bc98 --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/jsonrpc/ExtResponse.java @@ -0,0 +1,5 @@ +package com.alibaba.acp.sdk.protocol.jsonrpc; + +public class ExtResponse extends Response { + // This is a generic extension response that can hold any result +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/jsonrpc/Message.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/jsonrpc/Message.java new file mode 100644 index 000000000..1fc2cce8a --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/jsonrpc/Message.java @@ -0,0 +1,22 @@ +package com.alibaba.acp.sdk.protocol.jsonrpc; + +public class Message extends Meta { + String jsonrpc = "2.0"; + Object id; + + public String getJsonrpc() { + return jsonrpc; + } + + public void setJsonrpc(String jsonrpc) { + this.jsonrpc = jsonrpc; + } + + public Object getId() { + return id; + } + + public void setId(Object id) { + this.id = id; + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/jsonrpc/Meta.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/jsonrpc/Meta.java new file mode 100644 index 000000000..ed77d7332 --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/jsonrpc/Meta.java @@ -0,0 +1,48 @@ +package com.alibaba.acp.sdk.protocol.jsonrpc; + +import java.util.Map; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONWriter.Feature; +import com.alibaba.fastjson2.annotation.JSONField; + +/** + * Base class for objects that contain metadata. + * + * This class provides a generic way to store metadata in the form of key-value pairs + * that can be included in JSON-RPC messages for additional context or information. + * + * @author SkyFire + * @version 0.0.1 + */ +public class Meta { + @JSONField(name = "_meta") + Map meta; + + /** + * Gets the metadata map. + * + * @return The metadata map containing key-value pairs + */ + public Map getMeta() { + return meta; + } + + /** + * Sets the metadata map. + * + * @param meta The metadata map containing key-value pairs + */ + public void setMeta(Map meta) { + this.meta = meta; + } + + /** + * Converts this object to its JSON string representation. + * + * @return JSON string representation of this object + */ + public String toString() { + return JSON.toJSONString(this, Feature.FieldBased); + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/jsonrpc/MethodMessage.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/jsonrpc/MethodMessage.java new file mode 100644 index 000000000..62b1c1666 --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/jsonrpc/MethodMessage.java @@ -0,0 +1,72 @@ +package com.alibaba.acp.sdk.protocol.jsonrpc; + +import java.util.UUID; + +import com.alibaba.acp.sdk.protocol.agent.notification.SessionNotification; +import com.alibaba.acp.sdk.protocol.agent.request.AuthenticateRequest; +import com.alibaba.acp.sdk.protocol.agent.request.ReadTextFileRequest; +import com.alibaba.acp.sdk.protocol.agent.request.RequestPermissionRequest; +import com.alibaba.acp.sdk.protocol.agent.request.WriteTextFileRequest; +import com.alibaba.acp.sdk.protocol.agent.request.terminal.CreateTerminalRequest; +import com.alibaba.acp.sdk.protocol.agent.request.terminal.KillTerminalCommandRequest; +import com.alibaba.acp.sdk.protocol.agent.request.terminal.ReleaseTerminalRequest; +import com.alibaba.acp.sdk.protocol.agent.request.terminal.TerminalOutputRequest; +import com.alibaba.acp.sdk.protocol.agent.request.terminal.WaitForTerminalExitRequest; +import com.alibaba.acp.sdk.protocol.client.request.InitializeRequest; +import com.alibaba.acp.sdk.protocol.client.request.LoadSessionRequest; +import com.alibaba.acp.sdk.protocol.client.request.NewSessionRequest; +import com.alibaba.acp.sdk.protocol.client.request.PromptRequest; +import com.alibaba.acp.sdk.protocol.client.request.SetSessionModeRequest; +import com.alibaba.fastjson2.annotation.JSONType; + +import org.apache.commons.lang3.Validate; + +@JSONType(typeKey = "method", seeAlso = { + AuthenticateRequest.class, + ReadTextFileRequest.class, + RequestPermissionRequest.class, + WriteTextFileRequest.class, + CreateTerminalRequest.class, + KillTerminalCommandRequest.class, + ReleaseTerminalRequest.class, + TerminalOutputRequest.class, + WaitForTerminalExitRequest.class, + InitializeRequest.class, + LoadSessionRequest.class, + NewSessionRequest.class, + PromptRequest.class, + SetSessionModeRequest.class, + SessionNotification.class, +}) +public class MethodMessage

extends Message { + protected String method; + protected P params; + + public String getMethod() { + return method; + } + + public void setMethod(String method) { + this.method = method; + } + + public P getParams() { + return params; + } + + public void setParams(P params) { + this.params = params; + } + + public MethodMessage() { + this.id = UUID.randomUUID().toString(); + } + + public MethodMessage(String method, P params) { + this(); + Validate.notEmpty(method, "method can not be empty"); + this.method = method; + Validate.notNull(params, "params can not be null"); + this.params = params; + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/jsonrpc/Request.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/jsonrpc/Request.java new file mode 100644 index 000000000..4a70d7a29 --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/jsonrpc/Request.java @@ -0,0 +1,10 @@ +package com.alibaba.acp.sdk.protocol.jsonrpc; + +public class Request

extends MethodMessage

{ + public Request() { + } + + public Request(String method, P params) { + super(method, params); + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/jsonrpc/Response.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/jsonrpc/Response.java new file mode 100644 index 000000000..8dc282987 --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/jsonrpc/Response.java @@ -0,0 +1,35 @@ +package com.alibaba.acp.sdk.protocol.jsonrpc; + +public class Response extends Message { + R result; + Error error; + + public Response() { + } + + public Response(Object id, R result) { + this.id = id; + this.result = result; + } + + public Response(Object id, Error error) { + this.id = id; + this.error = error; + } + + public R getResult() { + return result; + } + + public void setResult(R result) { + this.result = result; + } + + public Error getError() { + return error; + } + + public void setError(Error error) { + this.error = error; + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/schema.json b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/schema.json new file mode 100644 index 000000000..eba179457 --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/protocol/schema.json @@ -0,0 +1,3105 @@ +{ + "$defs": { + "AgentCapabilities": { + "description": "Capabilities supported by the agent.\n\nAdvertised during initialization to inform the client about\navailable features and content types.\n\nSee protocol docs: [Agent Capabilities](https://agentclientprotocol.com/protocol/initialization#agent-capabilities)", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "loadSession": { + "default": false, + "description": "Whether the agent supports `session/load`.", + "type": "boolean" + }, + "mcpCapabilities": { + "allOf": [ + { + "$ref": "#/$defs/McpCapabilities" + } + ], + "default": { + "http": false, + "sse": false + }, + "description": "MCP capabilities supported by the agent." + }, + "promptCapabilities": { + "allOf": [ + { + "$ref": "#/$defs/PromptCapabilities" + } + ], + "default": { + "audio": false, + "embeddedContext": false, + "image": false + }, + "description": "Prompt capabilities supported by the agent." + }, + "sessionCapabilities": { + "allOf": [ + { + "$ref": "#/$defs/SessionCapabilities" + } + ], + "default": {} + } + }, + "type": "object" + }, + "AgentNotification": { + "properties": { + "method": { + "type": "string" + }, + "params": { + "anyOf": [ + { + "anyOf": [ + { + "allOf": [ + { + "$ref": "#/$defs/SessionNotification" + } + ], + "description": "Handles session update notifications from the agent.\n\nThis is a notification endpoint (no response expected) that receives\nreal-time updates about session progress, including message chunks,\ntool calls, and execution plans.\n\nNote: Clients SHOULD continue accepting tool call updates even after\nsending a `session/cancel` notification, as the agent may send final\nupdates before responding with the cancelled stop reason.\n\nSee protocol docs: [Agent Reports Output](https://agentclientprotocol.com/protocol/prompt-turn#3-agent-reports-output)", + "title": "SessionNotification" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ExtNotification" + } + ], + "description": "Handles extension notifications from the agent.\n\nAllows the Agent to send an arbitrary notification that is not part of the ACP spec.\nExtension notifications provide a way to send one-way messages for custom functionality\nwhile maintaining protocol compatibility.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "title": "ExtNotification" + } + ], + "description": "All possible notifications that an agent can send to a client.\n\nThis enum is used internally for routing RPC notifications. You typically won't need\nto use this directly - use the notification methods on the [`Client`] trait instead.\n\nNotifications do not expect a response." + }, + { + "type": "null" + } + ] + } + }, + "required": ["method"], + "type": "object", + "x-docs-ignore": true + }, + "AgentRequest": { + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "method": { + "type": "string" + }, + "params": { + "anyOf": [ + { + "anyOf": [ + { + "allOf": [ + { + "$ref": "#/$defs/WriteTextFileRequest" + } + ], + "description": "Writes content to a text file in the client's file system.\n\nOnly available if the client advertises the `fs.writeTextFile` capability.\nAllows the agent to create or modify files within the client's environment.\n\nSee protocol docs: [Client](https://agentclientprotocol.com/protocol/overview#client)", + "title": "WriteTextFileRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ReadTextFileRequest" + } + ], + "description": "Reads content from a text file in the client's file system.\n\nOnly available if the client advertises the `fs.readTextFile` capability.\nAllows the agent to access file contents within the client's environment.\n\nSee protocol docs: [Client](https://agentclientprotocol.com/protocol/overview#client)", + "title": "ReadTextFileRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/RequestPermissionRequest" + } + ], + "description": "Requests permission from the user for a tool call operation.\n\nCalled by the agent when it needs user authorization before executing\na potentially sensitive operation. The client should present the options\nto the user and return their decision.\n\nIf the client cancels the prompt turn via `session/cancel`, it MUST\nrespond to this request with `RequestPermissionOutcome::Cancelled`.\n\nSee protocol docs: [Requesting Permission](https://agentclientprotocol.com/protocol/tool-calls#requesting-permission)", + "title": "RequestPermissionRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/CreateTerminalRequest" + } + ], + "description": "Executes a command in a new terminal\n\nOnly available if the `terminal` Client capability is set to `true`.\n\nReturns a `TerminalId` that can be used with other terminal methods\nto get the current output, wait for exit, and kill the command.\n\nThe `TerminalId` can also be used to embed the terminal in a tool call\nby using the `ToolCallContent::Terminal` variant.\n\nThe Agent is responsible for releasing the terminal by using the `terminal/release`\nmethod.\n\nSee protocol docs: [Terminals](https://agentclientprotocol.com/protocol/terminals)", + "title": "CreateTerminalRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/TerminalOutputRequest" + } + ], + "description": "Gets the terminal output and exit status\n\nReturns the current content in the terminal without waiting for the command to exit.\nIf the command has already exited, the exit status is included.\n\nSee protocol docs: [Terminals](https://agentclientprotocol.com/protocol/terminals)", + "title": "TerminalOutputRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ReleaseTerminalRequest" + } + ], + "description": "Releases a terminal\n\nThe command is killed if it hasn't exited yet. Use `terminal/wait_for_exit`\nto wait for the command to exit before releasing the terminal.\n\nAfter release, the `TerminalId` can no longer be used with other `terminal/*` methods,\nbut tool calls that already contain it, continue to display its output.\n\nThe `terminal/kill` method can be used to terminate the command without releasing\nthe terminal, allowing the Agent to call `terminal/output` and other methods.\n\nSee protocol docs: [Terminals](https://agentclientprotocol.com/protocol/terminals)", + "title": "ReleaseTerminalRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/WaitForTerminalExitRequest" + } + ], + "description": "Waits for the terminal command to exit and return its exit status\n\nSee protocol docs: [Terminals](https://agentclientprotocol.com/protocol/terminals)", + "title": "WaitForTerminalExitRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/KillTerminalCommandRequest" + } + ], + "description": "Kills the terminal command without releasing the terminal\n\nWhile `terminal/release` will also kill the command, this method will keep\nthe `TerminalId` valid so it can be used with other methods.\n\nThis method can be helpful when implementing command timeouts which terminate\nthe command as soon as elapsed, and then get the final output so it can be sent\nto the model.\n\nNote: `terminal/release` when `TerminalId` is no longer needed.\n\nSee protocol docs: [Terminals](https://agentclientprotocol.com/protocol/terminals)", + "title": "KillTerminalCommandRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ExtRequest" + } + ], + "description": "Handles extension method requests from the agent.\n\nAllows the Agent to send an arbitrary request that is not part of the ACP spec.\nExtension methods provide a way to add custom functionality while maintaining\nprotocol compatibility.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "title": "ExtMethodRequest" + } + ], + "description": "All possible requests that an agent can send to a client.\n\nThis enum is used internally for routing RPC requests. You typically won't need\nto use this directly - instead, use the methods on the [`Client`] trait.\n\nThis enum encompasses all method calls from agent to client." + }, + { + "type": "null" + } + ] + } + }, + "required": ["id", "method"], + "type": "object", + "x-docs-ignore": true + }, + "AgentResponse": { + "anyOf": [ + { + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "result": { + "anyOf": [ + { + "allOf": [ + { + "$ref": "#/$defs/InitializeResponse" + } + ], + "title": "InitializeResponse" + }, + { + "allOf": [ + { + "$ref": "#/$defs/AuthenticateResponse" + } + ], + "title": "AuthenticateResponse" + }, + { + "allOf": [ + { + "$ref": "#/$defs/NewSessionResponse" + } + ], + "title": "NewSessionResponse" + }, + { + "allOf": [ + { + "$ref": "#/$defs/LoadSessionResponse" + } + ], + "title": "LoadSessionResponse" + }, + { + "allOf": [ + { + "$ref": "#/$defs/SetSessionModeResponse" + } + ], + "title": "SetSessionModeResponse" + }, + { + "allOf": [ + { + "$ref": "#/$defs/PromptResponse" + } + ], + "title": "PromptResponse" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ExtResponse" + } + ], + "title": "ExtMethodResponse" + } + ], + "description": "All possible responses that an agent can send to a client.\n\nThis enum is used internally for routing RPC responses. You typically won't need\nto use this directly - the responses are handled automatically by the connection.\n\nThese are responses to the corresponding `ClientRequest` variants." + } + }, + "required": ["id", "result"], + "title": "Result", + "type": "object" + }, + { + "properties": { + "error": { + "$ref": "#/$defs/Error" + }, + "id": { + "$ref": "#/$defs/RequestId" + } + }, + "required": ["id", "error"], + "title": "Error", + "type": "object" + } + ], + "x-docs-ignore": true + }, + "Annotations": { + "description": "Optional annotations for the client. The client can use annotations to inform how objects are used or displayed", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "audience": { + "items": { + "$ref": "#/$defs/Role" + }, + "type": ["array", "null"] + }, + "lastModified": { + "type": ["string", "null"] + }, + "priority": { + "format": "double", + "type": ["number", "null"] + } + }, + "type": "object" + }, + "AudioContent": { + "description": "Audio provided to or from an LLM.", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "annotations": { + "anyOf": [ + { + "$ref": "#/$defs/Annotations" + }, + { + "type": "null" + } + ] + }, + "data": { + "type": "string" + }, + "mimeType": { + "type": "string" + } + }, + "required": ["data", "mimeType"], + "type": "object" + }, + "AuthMethod": { + "description": "Describes an available authentication method.", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "description": { + "description": "Optional description providing more details about this authentication method.", + "type": ["string", "null"] + }, + "id": { + "description": "Unique identifier for this authentication method.", + "type": "string" + }, + "name": { + "description": "Human-readable name of the authentication method.", + "type": "string" + } + }, + "required": ["id", "name"], + "type": "object" + }, + "AuthenticateRequest": { + "description": "Request parameters for the authenticate method.\n\nSpecifies which authentication method to use.", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "methodId": { + "description": "The ID of the authentication method to use.\nMust be one of the methods advertised in the initialize response.", + "type": "string" + } + }, + "required": ["methodId"], + "type": "object", + "x-method": "authenticate", + "x-side": "agent" + }, + "AuthenticateResponse": { + "description": "Response to the `authenticate` method.", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + } + }, + "type": "object", + "x-method": "authenticate", + "x-side": "agent" + }, + "AvailableCommand": { + "description": "Information about a command.", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "description": { + "description": "Human-readable description of what the command does.", + "type": "string" + }, + "input": { + "anyOf": [ + { + "$ref": "#/$defs/AvailableCommandInput" + }, + { + "type": "null" + } + ], + "description": "Input for the command if required" + }, + "name": { + "description": "Command name (e.g., `create_plan`, `research_codebase`).", + "type": "string" + } + }, + "required": ["name", "description"], + "type": "object" + }, + "AvailableCommandInput": { + "anyOf": [ + { + "allOf": [ + { + "$ref": "#/$defs/UnstructuredCommandInput" + } + ], + "description": "All text that was typed after the command name is provided as input.", + "title": "unstructured" + } + ], + "description": "The input specification for a command." + }, + "AvailableCommandsUpdate": { + "description": "Available commands are ready or have changed", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "availableCommands": { + "description": "Commands the agent can execute", + "items": { + "$ref": "#/$defs/AvailableCommand" + }, + "type": "array" + } + }, + "required": ["availableCommands"], + "type": "object" + }, + "BlobResourceContents": { + "description": "Binary resource contents.", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "blob": { + "type": "string" + }, + "mimeType": { + "type": ["string", "null"] + }, + "uri": { + "type": "string" + } + }, + "required": ["blob", "uri"], + "type": "object" + }, + "CancelNotification": { + "description": "Notification to cancel ongoing operations for a session.\n\nSee protocol docs: [Cancellation](https://agentclientprotocol.com/protocol/prompt-turn#cancellation)", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "sessionId": { + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ], + "description": "The ID of the session to cancel operations for." + } + }, + "required": ["sessionId"], + "type": "object", + "x-method": "session/cancel", + "x-side": "agent" + }, + "ClientCapabilities": { + "description": "Capabilities supported by the client.\n\nAdvertised during initialization to inform the agent about\navailable features and methods.\n\nSee protocol docs: [Client Capabilities](https://agentclientprotocol.com/protocol/initialization#client-capabilities)", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "fs": { + "allOf": [ + { + "$ref": "#/$defs/FileSystemCapability" + } + ], + "default": { + "readTextFile": false, + "writeTextFile": false + }, + "description": "File system capabilities supported by the client.\nDetermines which file operations the agent can request." + }, + "terminal": { + "default": false, + "description": "Whether the Client support all `terminal/*` methods.", + "type": "boolean" + } + }, + "type": "object" + }, + "ClientNotification": { + "properties": { + "method": { + "type": "string" + }, + "params": { + "anyOf": [ + { + "anyOf": [ + { + "allOf": [ + { + "$ref": "#/$defs/CancelNotification" + } + ], + "description": "Cancels ongoing operations for a session.\n\nThis is a notification sent by the client to cancel an ongoing prompt turn.\n\nUpon receiving this notification, the Agent SHOULD:\n- Stop all language model requests as soon as possible\n- Abort all tool call invocations in progress\n- Send any pending `session/update` notifications\n- Respond to the original `session/prompt` request with `StopReason::Cancelled`\n\nSee protocol docs: [Cancellation](https://agentclientprotocol.com/protocol/prompt-turn#cancellation)", + "title": "CancelNotification" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ExtNotification" + } + ], + "description": "Handles extension notifications from the client.\n\nExtension notifications provide a way to send one-way messages for custom functionality\nwhile maintaining protocol compatibility.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "title": "ExtNotification" + } + ], + "description": "All possible notifications that a client can send to an agent.\n\nThis enum is used internally for routing RPC notifications. You typically won't need\nto use this directly - use the notification methods on the [`Agent`] trait instead.\n\nNotifications do not expect a response." + }, + { + "type": "null" + } + ] + } + }, + "required": ["method"], + "type": "object", + "x-docs-ignore": true + }, + "ClientRequest": { + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "method": { + "type": "string" + }, + "params": { + "anyOf": [ + { + "anyOf": [ + { + "allOf": [ + { + "$ref": "#/$defs/InitializeRequest" + } + ], + "description": "Establishes the connection with a client and negotiates protocol capabilities.\n\nThis method is called once at the beginning of the connection to:\n- Negotiate the protocol version to use\n- Exchange capability information between client and agent\n- Determine available authentication methods\n\nThe agent should respond with its supported protocol version and capabilities.\n\nSee protocol docs: [Initialization](https://agentclientprotocol.com/protocol/initialization)", + "title": "InitializeRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/AuthenticateRequest" + } + ], + "description": "Authenticates the client using the specified authentication method.\n\nCalled when the agent requires authentication before allowing session creation.\nThe client provides the authentication method ID that was advertised during initialization.\n\nAfter successful authentication, the client can proceed to create sessions with\n`new_session` without receiving an `auth_required` error.\n\nSee protocol docs: [Initialization](https://agentclientprotocol.com/protocol/initialization)", + "title": "AuthenticateRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/NewSessionRequest" + } + ], + "description": "Creates a new conversation session with the agent.\n\nSessions represent independent conversation contexts with their own history and state.\n\nThe agent should:\n- Create a new session context\n- Connect to any specified MCP servers\n- Return a unique session ID for future requests\n\nMay return an `auth_required` error if the agent requires authentication.\n\nSee protocol docs: [Session Setup](https://agentclientprotocol.com/protocol/session-setup)", + "title": "NewSessionRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/LoadSessionRequest" + } + ], + "description": "Loads an existing session to resume a previous conversation.\n\nThis method is only available if the agent advertises the `loadSession` capability.\n\nThe agent should:\n- Restore the session context and conversation history\n- Connect to the specified MCP servers\n- Stream the entire conversation history back to the client via notifications\n\nSee protocol docs: [Loading Sessions](https://agentclientprotocol.com/protocol/session-setup#loading-sessions)", + "title": "LoadSessionRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/SetSessionModeRequest" + } + ], + "description": "Sets the current mode for a session.\n\nAllows switching between different agent modes (e.g., \"ask\", \"architect\", \"code\")\nthat affect system prompts, tool availability, and permission behaviors.\n\nThe mode must be one of the modes advertised in `availableModes` during session\ncreation or loading. Agents may also change modes autonomously and notify the\nclient via `current_mode_update` notifications.\n\nThis method can be called at any time during a session, whether the Agent is\nidle or actively generating a response.\n\nSee protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes)", + "title": "SetSessionModeRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/PromptRequest" + } + ], + "description": "Processes a user prompt within a session.\n\nThis method handles the whole lifecycle of a prompt:\n- Receives user messages with optional context (files, images, etc.)\n- Processes the prompt using language models\n- Reports language model content and tool calls to the Clients\n- Requests permission to run tools\n- Executes any requested tool calls\n- Returns when the turn is complete with a stop reason\n\nSee protocol docs: [Prompt Turn](https://agentclientprotocol.com/protocol/prompt-turn)", + "title": "PromptRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ExtRequest" + } + ], + "description": "Handles extension method requests from the client.\n\nExtension methods provide a way to add custom functionality while maintaining\nprotocol compatibility.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "title": "ExtMethodRequest" + } + ], + "description": "All possible requests that a client can send to an agent.\n\nThis enum is used internally for routing RPC requests. You typically won't need\nto use this directly - instead, use the methods on the [`Agent`] trait.\n\nThis enum encompasses all method calls from client to agent." + }, + { + "type": "null" + } + ] + } + }, + "required": ["id", "method"], + "type": "object", + "x-docs-ignore": true + }, + "ClientResponse": { + "anyOf": [ + { + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "result": { + "anyOf": [ + { + "allOf": [ + { + "$ref": "#/$defs/WriteTextFileResponse" + } + ], + "title": "WriteTextFileResponse" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ReadTextFileResponse" + } + ], + "title": "ReadTextFileResponse" + }, + { + "allOf": [ + { + "$ref": "#/$defs/RequestPermissionResponse" + } + ], + "title": "RequestPermissionResponse" + }, + { + "allOf": [ + { + "$ref": "#/$defs/CreateTerminalResponse" + } + ], + "title": "CreateTerminalResponse" + }, + { + "allOf": [ + { + "$ref": "#/$defs/TerminalOutputResponse" + } + ], + "title": "TerminalOutputResponse" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ReleaseTerminalResponse" + } + ], + "title": "ReleaseTerminalResponse" + }, + { + "allOf": [ + { + "$ref": "#/$defs/WaitForTerminalExitResponse" + } + ], + "title": "WaitForTerminalExitResponse" + }, + { + "allOf": [ + { + "$ref": "#/$defs/KillTerminalCommandResponse" + } + ], + "title": "KillTerminalResponse" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ExtResponse" + } + ], + "title": "ExtMethodResponse" + } + ], + "description": "All possible responses that a client can send to an agent.\n\nThis enum is used internally for routing RPC responses. You typically won't need\nto use this directly - the responses are handled automatically by the connection.\n\nThese are responses to the corresponding `AgentRequest` variants." + } + }, + "required": ["id", "result"], + "title": "Result", + "type": "object" + }, + { + "properties": { + "error": { + "$ref": "#/$defs/Error" + }, + "id": { + "$ref": "#/$defs/RequestId" + } + }, + "required": ["id", "error"], + "title": "Error", + "type": "object" + } + ], + "x-docs-ignore": true + }, + "Content": { + "description": "Standard content block (text, images, resources).", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "content": { + "allOf": [ + { + "$ref": "#/$defs/ContentBlock" + } + ], + "description": "The actual content block." + } + }, + "required": ["content"], + "type": "object" + }, + "ContentBlock": { + "description": "Content blocks represent displayable information in the Agent Client Protocol.\n\nThey provide a structured way to handle various types of user-facing content—whether\nit's text from language models, images for analysis, or embedded resources for context.\n\nContent blocks appear in:\n- User prompts sent via `session/prompt`\n- Language model output streamed through `session/update` notifications\n- Progress updates and results from tool calls\n\nThis structure is compatible with the Model Context Protocol (MCP), enabling\nagents to seamlessly forward content from MCP tool outputs without transformation.\n\nSee protocol docs: [Content](https://agentclientprotocol.com/protocol/content)", + "discriminator": { + "propertyName": "type" + }, + "oneOf": [ + { + "allOf": [ + { + "$ref": "#/$defs/TextContent" + } + ], + "description": "Text content. May be plain text or formatted with Markdown.\n\nAll agents MUST support text content blocks in prompts.\nClients SHOULD render this text as Markdown.", + "properties": { + "type": { + "const": "text", + "type": "string" + } + }, + "required": ["type"], + "type": "object" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ImageContent" + } + ], + "description": "Images for visual context or analysis.\n\nRequires the `image` prompt capability when included in prompts.", + "properties": { + "type": { + "const": "image", + "type": "string" + } + }, + "required": ["type"], + "type": "object" + }, + { + "allOf": [ + { + "$ref": "#/$defs/AudioContent" + } + ], + "description": "Audio data for transcription or analysis.\n\nRequires the `audio` prompt capability when included in prompts.", + "properties": { + "type": { + "const": "audio", + "type": "string" + } + }, + "required": ["type"], + "type": "object" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ResourceLink" + } + ], + "description": "References to resources that the agent can access.\n\nAll agents MUST support resource links in prompts.", + "properties": { + "type": { + "const": "resource_link", + "type": "string" + } + }, + "required": ["type"], + "type": "object" + }, + { + "allOf": [ + { + "$ref": "#/$defs/EmbeddedResource" + } + ], + "description": "Complete resource contents embedded directly in the message.\n\nPreferred for including context as it avoids extra round-trips.\n\nRequires the `embeddedContext` prompt capability when included in prompts.", + "properties": { + "type": { + "const": "resource", + "type": "string" + } + }, + "required": ["type"], + "type": "object" + } + ] + }, + "ContentChunk": { + "description": "A streamed item of content", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "content": { + "allOf": [ + { + "$ref": "#/$defs/ContentBlock" + } + ], + "description": "A single item of content" + } + }, + "required": ["content"], + "type": "object" + }, + "CreateTerminalRequest": { + "description": "Request to create a new terminal and execute a command.", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "args": { + "description": "Array of command arguments.", + "items": { + "type": "string" + }, + "type": "array" + }, + "command": { + "description": "The command to execute.", + "type": "string" + }, + "cwd": { + "description": "Working directory for the command (absolute path).", + "type": ["string", "null"] + }, + "env": { + "description": "Environment variables for the command.", + "items": { + "$ref": "#/$defs/EnvVariable" + }, + "type": "array" + }, + "outputByteLimit": { + "description": "Maximum number of output bytes to retain.\n\nWhen the limit is exceeded, the Client truncates from the beginning of the output\nto stay within the limit.\n\nThe Client MUST ensure truncation happens at a character boundary to maintain valid\nstring output, even if this means the retained output is slightly less than the\nspecified limit.", + "format": "uint64", + "minimum": 0, + "type": ["integer", "null"] + }, + "sessionId": { + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ], + "description": "The session ID for this request." + } + }, + "required": ["sessionId", "command"], + "type": "object", + "x-method": "terminal/create", + "x-side": "client" + }, + "CreateTerminalResponse": { + "description": "Response containing the ID of the created terminal.", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "terminalId": { + "description": "The unique identifier for the created terminal.", + "type": "string" + } + }, + "required": ["terminalId"], + "type": "object", + "x-method": "terminal/create", + "x-side": "client" + }, + "CurrentModeUpdate": { + "description": "The current mode of the session has changed\n\nSee protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes)", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "currentModeId": { + "allOf": [ + { + "$ref": "#/$defs/SessionModeId" + } + ], + "description": "The ID of the current mode" + } + }, + "required": ["currentModeId"], + "type": "object" + }, + "Diff": { + "description": "A diff representing file modifications.\n\nShows changes to files in a format suitable for display in the client UI.\n\nSee protocol docs: [Content](https://agentclientprotocol.com/protocol/tool-calls#content)", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "newText": { + "description": "The new content after modification.", + "type": "string" + }, + "oldText": { + "description": "The original content (None for new files).", + "type": ["string", "null"] + }, + "path": { + "description": "The file path being modified.", + "type": "string" + } + }, + "required": ["path", "newText"], + "type": "object" + }, + "EmbeddedResource": { + "description": "The contents of a resource, embedded into a prompt or tool call result.", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "annotations": { + "anyOf": [ + { + "$ref": "#/$defs/Annotations" + }, + { + "type": "null" + } + ] + }, + "resource": { + "$ref": "#/$defs/EmbeddedResourceResource" + } + }, + "required": ["resource"], + "type": "object" + }, + "EmbeddedResourceResource": { + "anyOf": [ + { + "allOf": [ + { + "$ref": "#/$defs/TextResourceContents" + } + ], + "title": "TextResourceContents" + }, + { + "allOf": [ + { + "$ref": "#/$defs/BlobResourceContents" + } + ], + "title": "BlobResourceContents" + } + ], + "description": "Resource content that can be embedded in a message." + }, + "EnvVariable": { + "description": "An environment variable to set when launching an MCP server.", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "name": { + "description": "The name of the environment variable.", + "type": "string" + }, + "value": { + "description": "The value to set for the environment variable.", + "type": "string" + } + }, + "required": ["name", "value"], + "type": "object" + }, + "Error": { + "description": "JSON-RPC error object.\n\nRepresents an error that occurred during method execution, following the\nJSON-RPC 2.0 error object specification with optional additional data.\n\nSee protocol docs: [JSON-RPC Error Object](https://www.jsonrpc.org/specification#error_object)", + "properties": { + "code": { + "allOf": [ + { + "$ref": "#/$defs/ErrorCode" + } + ], + "description": "A number indicating the error type that occurred.\nThis must be an integer as defined in the JSON-RPC specification." + }, + "data": { + "description": "Optional primitive or structured value that contains additional information about the error.\nThis may include debugging information or context-specific details." + }, + "message": { + "description": "A string providing a short description of the error.\nThe message should be limited to a concise single sentence.", + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "ErrorCode": { + "anyOf": [ + { + "const": -32700, + "description": "**Parse error**: Invalid JSON was received by the server.\nAn error occurred on the server while parsing the JSON text.", + "format": "int32", + "title": "Parse error", + "type": "integer" + }, + { + "const": -32600, + "description": "**Invalid request**: The JSON sent is not a valid Request object.", + "format": "int32", + "title": "Invalid request", + "type": "integer" + }, + { + "const": -32601, + "description": "**Method not found**: The method does not exist or is not available.", + "format": "int32", + "title": "Method not found", + "type": "integer" + }, + { + "const": -32602, + "description": "**Invalid params**: Invalid method parameter(s).", + "format": "int32", + "title": "Invalid params", + "type": "integer" + }, + { + "const": -32603, + "description": "**Internal error**: Internal JSON-RPC error.\nReserved for implementation-defined server errors.", + "format": "int32", + "title": "Internal error", + "type": "integer" + }, + { + "const": -32000, + "description": "**Authentication required**: Authentication is required before this operation can be performed.", + "format": "int32", + "title": "Authentication required", + "type": "integer" + }, + { + "const": -32002, + "description": "**Resource not found**: A given resource, such as a file, was not found.", + "format": "int32", + "title": "Resource not found", + "type": "integer" + }, + { + "description": "Other undefined error code.", + "format": "int32", + "title": "Other", + "type": "integer" + } + ], + "description": "Predefined error codes for common JSON-RPC and ACP-specific errors.\n\nThese codes follow the JSON-RPC 2.0 specification for standard errors\nand use the reserved range (-32000 to -32099) for protocol-specific errors." + }, + "ExtNotification": { + "description": "Allows the Agent to send an arbitrary notification that is not part of the ACP spec.\nExtension notifications provide a way to send one-way messages for custom functionality\nwhile maintaining protocol compatibility.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)" + }, + "ExtRequest": { + "description": "Allows for sending an arbitrary request that is not part of the ACP spec.\nExtension methods provide a way to add custom functionality while maintaining\nprotocol compatibility.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)" + }, + "ExtResponse": { + "description": "Allows for sending an arbitrary response to an [`ExtRequest`] that is not part of the ACP spec.\nExtension methods provide a way to add custom functionality while maintaining\nprotocol compatibility.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)" + }, + "FileSystemCapability": { + "description": "Filesystem capabilities supported by the client.\nFile system capabilities that a client may support.\n\nSee protocol docs: [FileSystem](https://agentclientprotocol.com/protocol/initialization#filesystem)", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "readTextFile": { + "default": false, + "description": "Whether the Client supports `fs/read_text_file` requests.", + "type": "boolean" + }, + "writeTextFile": { + "default": false, + "description": "Whether the Client supports `fs/write_text_file` requests.", + "type": "boolean" + } + }, + "type": "object" + }, + "HttpHeader": { + "description": "An HTTP header to set when making requests to the MCP server.", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "name": { + "description": "The name of the HTTP header.", + "type": "string" + }, + "value": { + "description": "The value to set for the HTTP header.", + "type": "string" + } + }, + "required": ["name", "value"], + "type": "object" + }, + "ImageContent": { + "description": "An image provided to or from an LLM.", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "annotations": { + "anyOf": [ + { + "$ref": "#/$defs/Annotations" + }, + { + "type": "null" + } + ] + }, + "data": { + "type": "string" + }, + "mimeType": { + "type": "string" + }, + "uri": { + "type": ["string", "null"] + } + }, + "required": ["data", "mimeType"], + "type": "object" + }, + "Implementation": { + "description": "Metadata about the implementation of the client or agent.\nDescribes the name and version of an MCP implementation, with an optional\ntitle for UI representation.", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "name": { + "description": "Intended for programmatic or logical use, but can be used as a display\nname fallback if title isn’t present.", + "type": "string" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable\nand easily understood.\n\nIf not provided, the name should be used for display.", + "type": ["string", "null"] + }, + "version": { + "description": "Version of the implementation. Can be displayed to the user or used\nfor debugging or metrics purposes. (e.g. \"1.0.0\").", + "type": "string" + } + }, + "required": ["name", "version"], + "type": "object" + }, + "InitializeRequest": { + "description": "Request parameters for the initialize method.\n\nSent by the client to establish connection and negotiate capabilities.\n\nSee protocol docs: [Initialization](https://agentclientprotocol.com/protocol/initialization)", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "clientCapabilities": { + "allOf": [ + { + "$ref": "#/$defs/ClientCapabilities" + } + ], + "default": { + "fs": { + "readTextFile": false, + "writeTextFile": false + }, + "terminal": false + }, + "description": "Capabilities supported by the client." + }, + "clientInfo": { + "anyOf": [ + { + "$ref": "#/$defs/Implementation" + }, + { + "type": "null" + } + ], + "description": "Information about the Client name and version sent to the Agent.\n\nNote: in future versions of the protocol, this will be required." + }, + "protocolVersion": { + "allOf": [ + { + "$ref": "#/$defs/ProtocolVersion" + } + ], + "description": "The latest protocol version supported by the client." + } + }, + "required": ["protocolVersion"], + "type": "object", + "x-method": "initialize", + "x-side": "agent" + }, + "InitializeResponse": { + "description": "Response to the `initialize` method.\n\nContains the negotiated protocol version and agent capabilities.\n\nSee protocol docs: [Initialization](https://agentclientprotocol.com/protocol/initialization)", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "agentCapabilities": { + "allOf": [ + { + "$ref": "#/$defs/AgentCapabilities" + } + ], + "default": { + "loadSession": false, + "mcpCapabilities": { + "http": false, + "sse": false + }, + "promptCapabilities": { + "audio": false, + "embeddedContext": false, + "image": false + }, + "sessionCapabilities": {} + }, + "description": "Capabilities supported by the agent." + }, + "agentInfo": { + "anyOf": [ + { + "$ref": "#/$defs/Implementation" + }, + { + "type": "null" + } + ], + "description": "Information about the Agent name and version sent to the Client.\n\nNote: in future versions of the protocol, this will be required." + }, + "authMethods": { + "default": [], + "description": "Authentication methods supported by the agent.", + "items": { + "$ref": "#/$defs/AuthMethod" + }, + "type": "array" + }, + "protocolVersion": { + "allOf": [ + { + "$ref": "#/$defs/ProtocolVersion" + } + ], + "description": "The protocol version the client specified if supported by the agent,\nor the latest protocol version supported by the agent.\n\nThe client should disconnect, if it doesn't support this version." + } + }, + "required": ["protocolVersion"], + "type": "object", + "x-method": "initialize", + "x-side": "agent" + }, + "KillTerminalCommandRequest": { + "description": "Request to kill a terminal command without releasing the terminal.", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "sessionId": { + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ], + "description": "The session ID for this request." + }, + "terminalId": { + "description": "The ID of the terminal to kill.", + "type": "string" + } + }, + "required": ["sessionId", "terminalId"], + "type": "object", + "x-method": "terminal/kill", + "x-side": "client" + }, + "KillTerminalCommandResponse": { + "description": "Response to terminal/kill command method", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + } + }, + "type": "object", + "x-method": "terminal/kill", + "x-side": "client" + }, + "LoadSessionRequest": { + "description": "Request parameters for loading an existing session.\n\nOnly available if the Agent supports the `loadSession` capability.\n\nSee protocol docs: [Loading Sessions](https://agentclientprotocol.com/protocol/session-setup#loading-sessions)", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "cwd": { + "description": "The working directory for this session.", + "type": "string" + }, + "mcpServers": { + "description": "List of MCP servers to connect to for this session.", + "items": { + "$ref": "#/$defs/McpServer" + }, + "type": "array" + }, + "sessionId": { + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ], + "description": "The ID of the session to load." + } + }, + "required": ["mcpServers", "cwd", "sessionId"], + "type": "object", + "x-method": "session/load", + "x-side": "agent" + }, + "LoadSessionResponse": { + "description": "Response from loading an existing session.", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "modes": { + "anyOf": [ + { + "$ref": "#/$defs/SessionModeState" + }, + { + "type": "null" + } + ], + "description": "Initial mode state if supported by the Agent\n\nSee protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes)" + } + }, + "type": "object", + "x-method": "session/load", + "x-side": "agent" + }, + "McpCapabilities": { + "description": "MCP capabilities supported by the agent", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "http": { + "default": false, + "description": "Agent supports [`McpServer::Http`].", + "type": "boolean" + }, + "sse": { + "default": false, + "description": "Agent supports [`McpServer::Sse`].", + "type": "boolean" + } + }, + "type": "object" + }, + "McpServer": { + "anyOf": [ + { + "allOf": [ + { + "$ref": "#/$defs/McpServerHttp" + } + ], + "description": "HTTP transport configuration\n\nOnly available when the Agent capabilities indicate `mcp_capabilities.http` is `true`.", + "properties": { + "type": { + "const": "http", + "type": "string" + } + }, + "required": ["type"], + "type": "object" + }, + { + "allOf": [ + { + "$ref": "#/$defs/McpServerSse" + } + ], + "description": "SSE transport configuration\n\nOnly available when the Agent capabilities indicate `mcp_capabilities.sse` is `true`.", + "properties": { + "type": { + "const": "sse", + "type": "string" + } + }, + "required": ["type"], + "type": "object" + }, + { + "allOf": [ + { + "$ref": "#/$defs/McpServerStdio" + } + ], + "description": "Stdio transport configuration\n\nAll Agents MUST support this transport.", + "title": "stdio" + } + ], + "description": "Configuration for connecting to an MCP (Model Context Protocol) server.\n\nMCP servers provide tools and context that the agent can use when\nprocessing prompts.\n\nSee protocol docs: [MCP Servers](https://agentclientprotocol.com/protocol/session-setup#mcp-servers)" + }, + "McpServerHttp": { + "description": "HTTP transport configuration for MCP.", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "headers": { + "description": "HTTP headers to set when making requests to the MCP server.", + "items": { + "$ref": "#/$defs/HttpHeader" + }, + "type": "array" + }, + "name": { + "description": "Human-readable name identifying this MCP server.", + "type": "string" + }, + "url": { + "description": "URL to the MCP server.", + "type": "string" + } + }, + "required": ["name", "url", "headers"], + "type": "object" + }, + "McpServerSse": { + "description": "SSE transport configuration for MCP.", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "headers": { + "description": "HTTP headers to set when making requests to the MCP server.", + "items": { + "$ref": "#/$defs/HttpHeader" + }, + "type": "array" + }, + "name": { + "description": "Human-readable name identifying this MCP server.", + "type": "string" + }, + "url": { + "description": "URL to the MCP server.", + "type": "string" + } + }, + "required": ["name", "url", "headers"], + "type": "object" + }, + "McpServerStdio": { + "description": "Stdio transport configuration for MCP.", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "args": { + "description": "Command-line arguments to pass to the MCP server.", + "items": { + "type": "string" + }, + "type": "array" + }, + "command": { + "description": "Path to the MCP server executable.", + "type": "string" + }, + "env": { + "description": "Environment variables to set when launching the MCP server.", + "items": { + "$ref": "#/$defs/EnvVariable" + }, + "type": "array" + }, + "name": { + "description": "Human-readable name identifying this MCP server.", + "type": "string" + } + }, + "required": ["name", "command", "args", "env"], + "type": "object" + }, + "NewSessionRequest": { + "description": "Request parameters for creating a new session.\n\nSee protocol docs: [Creating a Session](https://agentclientprotocol.com/protocol/session-setup#creating-a-session)", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "cwd": { + "description": "The working directory for this session. Must be an absolute path.", + "type": "string" + }, + "mcpServers": { + "description": "List of MCP (Model Context Protocol) servers the agent should connect to.", + "items": { + "$ref": "#/$defs/McpServer" + }, + "type": "array" + } + }, + "required": ["cwd", "mcpServers"], + "type": "object", + "x-method": "session/new", + "x-side": "agent" + }, + "NewSessionResponse": { + "description": "Response from creating a new session.\n\nSee protocol docs: [Creating a Session](https://agentclientprotocol.com/protocol/session-setup#creating-a-session)", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "modes": { + "anyOf": [ + { + "$ref": "#/$defs/SessionModeState" + }, + { + "type": "null" + } + ], + "description": "Initial mode state if supported by the Agent\n\nSee protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes)" + }, + "sessionId": { + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ], + "description": "Unique identifier for the created session.\n\nUsed in all subsequent requests for this conversation." + } + }, + "required": ["sessionId"], + "type": "object", + "x-method": "session/new", + "x-side": "agent" + }, + "PermissionOption": { + "description": "An option presented to the user when requesting permission.", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "kind": { + "allOf": [ + { + "$ref": "#/$defs/PermissionOptionKind" + } + ], + "description": "Hint about the nature of this permission option." + }, + "name": { + "description": "Human-readable label to display to the user.", + "type": "string" + }, + "optionId": { + "allOf": [ + { + "$ref": "#/$defs/PermissionOptionId" + } + ], + "description": "Unique identifier for this permission option." + } + }, + "required": ["optionId", "name", "kind"], + "type": "object" + }, + "PermissionOptionId": { + "description": "Unique identifier for a permission option.", + "type": "string" + }, + "PermissionOptionKind": { + "description": "The type of permission option being presented to the user.\n\nHelps clients choose appropriate icons and UI treatment.", + "oneOf": [ + { + "const": "allow_once", + "description": "Allow this operation only this time.", + "type": "string" + }, + { + "const": "allow_always", + "description": "Allow this operation and remember the choice.", + "type": "string" + }, + { + "const": "reject_once", + "description": "Reject this operation only this time.", + "type": "string" + }, + { + "const": "reject_always", + "description": "Reject this operation and remember the choice.", + "type": "string" + } + ] + }, + "Plan": { + "description": "An execution plan for accomplishing complex tasks.\n\nPlans consist of multiple entries representing individual tasks or goals.\nAgents report plans to clients to provide visibility into their execution strategy.\nPlans can evolve during execution as the agent discovers new requirements or completes tasks.\n\nSee protocol docs: [Agent Plan](https://agentclientprotocol.com/protocol/agent-plan)", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "entries": { + "description": "The list of tasks to be accomplished.\n\nWhen updating a plan, the agent must send a complete list of all entries\nwith their current status. The client replaces the entire plan with each update.", + "items": { + "$ref": "#/$defs/PlanEntry" + }, + "type": "array" + } + }, + "required": ["entries"], + "type": "object" + }, + "PlanEntry": { + "description": "A single entry in the execution plan.\n\nRepresents a task or goal that the assistant intends to accomplish\nas part of fulfilling the user's request.\nSee protocol docs: [Plan Entries](https://agentclientprotocol.com/protocol/agent-plan#plan-entries)", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "content": { + "description": "Human-readable description of what this task aims to accomplish.", + "type": "string" + }, + "priority": { + "allOf": [ + { + "$ref": "#/$defs/PlanEntryPriority" + } + ], + "description": "The relative importance of this task.\nUsed to indicate which tasks are most critical to the overall goal." + }, + "status": { + "allOf": [ + { + "$ref": "#/$defs/PlanEntryStatus" + } + ], + "description": "Current execution status of this task." + } + }, + "required": ["content", "priority", "status"], + "type": "object" + }, + "PlanEntryPriority": { + "description": "Priority levels for plan entries.\n\nUsed to indicate the relative importance or urgency of different\ntasks in the execution plan.\nSee protocol docs: [Plan Entries](https://agentclientprotocol.com/protocol/agent-plan#plan-entries)", + "oneOf": [ + { + "const": "high", + "description": "High priority task - critical to the overall goal.", + "type": "string" + }, + { + "const": "medium", + "description": "Medium priority task - important but not critical.", + "type": "string" + }, + { + "const": "low", + "description": "Low priority task - nice to have but not essential.", + "type": "string" + } + ] + }, + "PlanEntryStatus": { + "description": "Status of a plan entry in the execution flow.\n\nTracks the lifecycle of each task from planning through completion.\nSee protocol docs: [Plan Entries](https://agentclientprotocol.com/protocol/agent-plan#plan-entries)", + "oneOf": [ + { + "const": "pending", + "description": "The task has not started yet.", + "type": "string" + }, + { + "const": "in_progress", + "description": "The task is currently being worked on.", + "type": "string" + }, + { + "const": "completed", + "description": "The task has been successfully completed.", + "type": "string" + } + ] + }, + "PromptCapabilities": { + "description": "Prompt capabilities supported by the agent in `session/prompt` requests.\n\nBaseline agent functionality requires support for [`ContentBlock::Text`]\nand [`ContentBlock::ResourceLink`] in prompt requests.\n\nOther variants must be explicitly opted in to.\nCapabilities for different types of content in prompt requests.\n\nIndicates which content types beyond the baseline (text and resource links)\nthe agent can process.\n\nSee protocol docs: [Prompt Capabilities](https://agentclientprotocol.com/protocol/initialization#prompt-capabilities)", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "audio": { + "default": false, + "description": "Agent supports [`ContentBlock::Audio`].", + "type": "boolean" + }, + "embeddedContext": { + "default": false, + "description": "Agent supports embedded context in `session/prompt` requests.\n\nWhen enabled, the Client is allowed to include [`ContentBlock::Resource`]\nin prompt requests for pieces of context that are referenced in the message.", + "type": "boolean" + }, + "image": { + "default": false, + "description": "Agent supports [`ContentBlock::Image`].", + "type": "boolean" + } + }, + "type": "object" + }, + "PromptRequest": { + "description": "Request parameters for sending a user prompt to the agent.\n\nContains the user's message and any additional context.\n\nSee protocol docs: [User Message](https://agentclientprotocol.com/protocol/prompt-turn#1-user-message)", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "prompt": { + "description": "The blocks of content that compose the user's message.\n\nAs a baseline, the Agent MUST support [`ContentBlock::Text`] and [`ContentBlock::ResourceLink`],\nwhile other variants are optionally enabled via [`PromptCapabilities`].\n\nThe Client MUST adapt its interface according to [`PromptCapabilities`].\n\nThe client MAY include referenced pieces of context as either\n[`ContentBlock::Resource`] or [`ContentBlock::ResourceLink`].\n\nWhen available, [`ContentBlock::Resource`] is preferred\nas it avoids extra round-trips and allows the message to include\npieces of context from sources the agent may not have access to.", + "items": { + "$ref": "#/$defs/ContentBlock" + }, + "type": "array" + }, + "sessionId": { + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ], + "description": "The ID of the session to send this user message to" + } + }, + "required": ["sessionId", "prompt"], + "type": "object", + "x-method": "session/prompt", + "x-side": "agent" + }, + "PromptResponse": { + "description": "Response from processing a user prompt.\n\nSee protocol docs: [Check for Completion](https://agentclientprotocol.com/protocol/prompt-turn#4-check-for-completion)", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "stopReason": { + "allOf": [ + { + "$ref": "#/$defs/StopReason" + } + ], + "description": "Indicates why the agent stopped processing the turn." + } + }, + "required": ["stopReason"], + "type": "object", + "x-method": "session/prompt", + "x-side": "agent" + }, + "ProtocolVersion": { + "description": "Protocol version identifier.\n\nThis version is only bumped for breaking changes.\nNon-breaking changes should be introduced via capabilities.", + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": "integer" + }, + "ReadTextFileRequest": { + "description": "Request to read content from a text file.\n\nOnly available if the client supports the `fs.readTextFile` capability.", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "limit": { + "description": "Maximum number of lines to read.", + "format": "uint32", + "minimum": 0, + "type": ["integer", "null"] + }, + "line": { + "description": "Line number to start reading from (1-based).", + "format": "uint32", + "minimum": 0, + "type": ["integer", "null"] + }, + "path": { + "description": "Absolute path to the file to read.", + "type": "string" + }, + "sessionId": { + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ], + "description": "The session ID for this request." + } + }, + "required": ["sessionId", "path"], + "type": "object", + "x-method": "fs/read_text_file", + "x-side": "client" + }, + "ReadTextFileResponse": { + "description": "Response containing the contents of a text file.", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "content": { + "type": "string" + } + }, + "required": ["content"], + "type": "object", + "x-method": "fs/read_text_file", + "x-side": "client" + }, + "ReleaseTerminalRequest": { + "description": "Request to release a terminal and free its resources.", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "sessionId": { + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ], + "description": "The session ID for this request." + }, + "terminalId": { + "description": "The ID of the terminal to release.", + "type": "string" + } + }, + "required": ["sessionId", "terminalId"], + "type": "object", + "x-method": "terminal/release", + "x-side": "client" + }, + "ReleaseTerminalResponse": { + "description": "Response to terminal/release method", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + } + }, + "type": "object", + "x-method": "terminal/release", + "x-side": "client" + }, + "RequestId": { + "anyOf": [ + { + "title": "Null", + "type": "null" + }, + { + "format": "int64", + "title": "Number", + "type": "integer" + }, + { + "title": "Str", + "type": "string" + } + ], + "description": "JSON RPC Request Id\n\nAn identifier established by the Client that MUST contain a String, Number, or NULL value if included. If it is not included it is assumed to be a notification. The value SHOULD normally not be Null [1] and Numbers SHOULD NOT contain fractional parts [2]\n\nThe Server MUST reply with the same value in the Response object if included. This member is used to correlate the context between the two objects.\n\n[1] The use of Null as a value for the id member in a Request object is discouraged, because this specification uses a value of Null for Responses with an unknown id. Also, because JSON-RPC 1.0 uses an id value of Null for Notifications this could cause confusion in handling.\n\n[2] Fractional parts may be problematic, since many decimal fractions cannot be represented exactly as binary fractions." + }, + "RequestPermissionOutcome": { + "description": "The outcome of a permission request.", + "discriminator": { + "propertyName": "outcome" + }, + "oneOf": [ + { + "description": "The prompt turn was cancelled before the user responded.\n\nWhen a client sends a `session/cancel` notification to cancel an ongoing\nprompt turn, it MUST respond to all pending `session/request_permission`\nrequests with this `Cancelled` outcome.\n\nSee protocol docs: [Cancellation](https://agentclientprotocol.com/protocol/prompt-turn#cancellation)", + "properties": { + "outcome": { + "const": "cancelled", + "type": "string" + } + }, + "required": ["outcome"], + "type": "object" + }, + { + "allOf": [ + { + "$ref": "#/$defs/SelectedPermissionOutcome" + } + ], + "description": "The user selected one of the provided options.", + "properties": { + "outcome": { + "const": "selected", + "type": "string" + } + }, + "required": ["outcome"], + "type": "object" + } + ] + }, + "RequestPermissionRequest": { + "description": "Request for user permission to execute a tool call.\n\nSent when the agent needs authorization before performing a sensitive operation.\n\nSee protocol docs: [Requesting Permission](https://agentclientprotocol.com/protocol/tool-calls#requesting-permission)", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "options": { + "description": "Available permission options for the user to choose from.", + "items": { + "$ref": "#/$defs/PermissionOption" + }, + "type": "array" + }, + "sessionId": { + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ], + "description": "The session ID for this request." + }, + "toolCall": { + "allOf": [ + { + "$ref": "#/$defs/ToolCallUpdate" + } + ], + "description": "Details about the tool call requiring permission." + } + }, + "required": ["sessionId", "toolCall", "options"], + "type": "object", + "x-method": "session/request_permission", + "x-side": "client" + }, + "RequestPermissionResponse": { + "description": "Response to a permission request.", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "outcome": { + "allOf": [ + { + "$ref": "#/$defs/RequestPermissionOutcome" + } + ], + "description": "The user's decision on the permission request." + } + }, + "required": ["outcome"], + "type": "object", + "x-method": "session/request_permission", + "x-side": "client" + }, + "ResourceLink": { + "description": "A resource that the server is capable of reading, included in a prompt or tool call result.", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "annotations": { + "anyOf": [ + { + "$ref": "#/$defs/Annotations" + }, + { + "type": "null" + } + ] + }, + "description": { + "type": ["string", "null"] + }, + "mimeType": { + "type": ["string", "null"] + }, + "name": { + "type": "string" + }, + "size": { + "format": "int64", + "type": ["integer", "null"] + }, + "title": { + "type": ["string", "null"] + }, + "uri": { + "type": "string" + } + }, + "required": ["name", "uri"], + "type": "object" + }, + "Role": { + "description": "The sender or recipient of messages and data in a conversation.", + "enum": ["assistant", "user"], + "type": "string" + }, + "SelectedPermissionOutcome": { + "description": "The user selected one of the provided options.", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "optionId": { + "allOf": [ + { + "$ref": "#/$defs/PermissionOptionId" + } + ], + "description": "The ID of the option the user selected." + } + }, + "required": ["optionId"], + "type": "object" + }, + "SessionCapabilities": { + "description": "Session capabilities supported by the agent.\n\nAs a baseline, all Agents **MUST** support `session/new`, `session/prompt`, `session/cancel`, and `session/update`.\n\nOptionally, they **MAY** support other session methods and notifications by specifying additional capabilities.\n\nNote: `session/load` is still handled by the top-level `load_session` capability. This will be unified in future versions of the protocol.\n\nSee protocol docs: [Session Capabilities](https://agentclientprotocol.com/protocol/initialization#session-capabilities)", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + } + }, + "type": "object" + }, + "SessionId": { + "description": "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)", + "type": "string" + }, + "SessionMode": { + "description": "A mode the agent can operate in.\n\nSee protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes)", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "description": { + "type": ["string", "null"] + }, + "id": { + "$ref": "#/$defs/SessionModeId" + }, + "name": { + "type": "string" + } + }, + "required": ["id", "name"], + "type": "object" + }, + "SessionModeId": { + "description": "Unique identifier for a Session Mode.", + "type": "string" + }, + "SessionModeState": { + "description": "The set of modes and the one currently active.", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "availableModes": { + "description": "The set of modes that the Agent can operate in", + "items": { + "$ref": "#/$defs/SessionMode" + }, + "type": "array" + }, + "currentModeId": { + "allOf": [ + { + "$ref": "#/$defs/SessionModeId" + } + ], + "description": "The current mode the Agent is in." + } + }, + "required": ["currentModeId", "availableModes"], + "type": "object" + }, + "SessionNotification": { + "description": "Notification containing a session update from the agent.\n\nUsed to stream real-time progress and results during prompt processing.\n\nSee protocol docs: [Agent Reports Output](https://agentclientprotocol.com/protocol/prompt-turn#3-agent-reports-output)", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "sessionId": { + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ], + "description": "The ID of the session this update pertains to." + }, + "update": { + "allOf": [ + { + "$ref": "#/$defs/SessionUpdate" + } + ], + "description": "The actual update content." + } + }, + "required": ["sessionId", "update"], + "type": "object", + "x-method": "session/update", + "x-side": "client" + }, + "SessionUpdate": { + "description": "Different types of updates that can be sent during session processing.\n\nThese updates provide real-time feedback about the agent's progress.\n\nSee protocol docs: [Agent Reports Output](https://agentclientprotocol.com/protocol/prompt-turn#3-agent-reports-output)", + "discriminator": { + "propertyName": "sessionUpdate" + }, + "oneOf": [ + { + "allOf": [ + { + "$ref": "#/$defs/ContentChunk" + } + ], + "description": "A chunk of the user's message being streamed.", + "properties": { + "sessionUpdate": { + "const": "user_message_chunk", + "type": "string" + } + }, + "required": ["sessionUpdate"], + "type": "object" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ContentChunk" + } + ], + "description": "A chunk of the agent's response being streamed.", + "properties": { + "sessionUpdate": { + "const": "agent_message_chunk", + "type": "string" + } + }, + "required": ["sessionUpdate"], + "type": "object" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ContentChunk" + } + ], + "description": "A chunk of the agent's internal reasoning being streamed.", + "properties": { + "sessionUpdate": { + "const": "agent_thought_chunk", + "type": "string" + } + }, + "required": ["sessionUpdate"], + "type": "object" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ToolCall" + } + ], + "description": "Notification that a new tool call has been initiated.", + "properties": { + "sessionUpdate": { + "const": "tool_call", + "type": "string" + } + }, + "required": ["sessionUpdate"], + "type": "object" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ToolCallUpdate" + } + ], + "description": "Update on the status or results of a tool call.", + "properties": { + "sessionUpdate": { + "const": "tool_call_update", + "type": "string" + } + }, + "required": ["sessionUpdate"], + "type": "object" + }, + { + "allOf": [ + { + "$ref": "#/$defs/Plan" + } + ], + "description": "The agent's execution plan for complex tasks.\nSee protocol docs: [Agent Plan](https://agentclientprotocol.com/protocol/agent-plan)", + "properties": { + "sessionUpdate": { + "const": "plan", + "type": "string" + } + }, + "required": ["sessionUpdate"], + "type": "object" + }, + { + "allOf": [ + { + "$ref": "#/$defs/AvailableCommandsUpdate" + } + ], + "description": "Available commands are ready or have changed", + "properties": { + "sessionUpdate": { + "const": "available_commands_update", + "type": "string" + } + }, + "required": ["sessionUpdate"], + "type": "object" + }, + { + "allOf": [ + { + "$ref": "#/$defs/CurrentModeUpdate" + } + ], + "description": "The current mode of the session has changed\n\nSee protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes)", + "properties": { + "sessionUpdate": { + "const": "current_mode_update", + "type": "string" + } + }, + "required": ["sessionUpdate"], + "type": "object" + } + ] + }, + "SetSessionModeRequest": { + "description": "Request parameters for setting a session mode.", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "modeId": { + "allOf": [ + { + "$ref": "#/$defs/SessionModeId" + } + ], + "description": "The ID of the mode to set." + }, + "sessionId": { + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ], + "description": "The ID of the session to set the mode for." + } + }, + "required": ["sessionId", "modeId"], + "type": "object", + "x-method": "session/set_mode", + "x-side": "agent" + }, + "SetSessionModeResponse": { + "description": "Response to `session/set_mode` method.", + "properties": { + "_meta": { + "additionalProperties": true, + "type": ["object", "null"] + } + }, + "type": "object", + "x-method": "session/set_mode", + "x-side": "agent" + }, + "StopReason": { + "description": "Reasons why an agent stops processing a prompt turn.\n\nSee protocol docs: [Stop Reasons](https://agentclientprotocol.com/protocol/prompt-turn#stop-reasons)", + "oneOf": [ + { + "const": "end_turn", + "description": "The turn ended successfully.", + "type": "string" + }, + { + "const": "max_tokens", + "description": "The turn ended because the agent reached the maximum number of tokens.", + "type": "string" + }, + { + "const": "max_turn_requests", + "description": "The turn ended because the agent reached the maximum number of allowed\nagent requests between user turns.", + "type": "string" + }, + { + "const": "refusal", + "description": "The turn ended because the agent refused to continue. The user prompt\nand everything that comes after it won't be included in the next\nprompt, so this should be reflected in the UI.", + "type": "string" + }, + { + "const": "cancelled", + "description": "The turn was cancelled by the client via `session/cancel`.\n\nThis stop reason MUST be returned when the client sends a `session/cancel`\nnotification, even if the cancellation causes exceptions in underlying operations.\nAgents should catch these exceptions and return this semantically meaningful\nresponse to confirm successful cancellation.", + "type": "string" + } + ] + }, + "Terminal": { + "description": "Embed a terminal created with `terminal/create` by its id.\n\nThe terminal must be added before calling `terminal/release`.\n\nSee protocol docs: [Terminal](https://agentclientprotocol.com/protocol/terminals)", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "terminalId": { + "type": "string" + } + }, + "required": ["terminalId"], + "type": "object" + }, + "TerminalExitStatus": { + "description": "Exit status of a terminal command.", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "exitCode": { + "description": "The process exit code (may be null if terminated by signal).", + "format": "uint32", + "minimum": 0, + "type": ["integer", "null"] + }, + "signal": { + "description": "The signal that terminated the process (may be null if exited normally).", + "type": ["string", "null"] + } + }, + "type": "object" + }, + "TerminalOutputRequest": { + "description": "Request to get the current output and status of a terminal.", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "sessionId": { + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ], + "description": "The session ID for this request." + }, + "terminalId": { + "description": "The ID of the terminal to get output from.", + "type": "string" + } + }, + "required": ["sessionId", "terminalId"], + "type": "object", + "x-method": "terminal/output", + "x-side": "client" + }, + "TerminalOutputResponse": { + "description": "Response containing the terminal output and exit status.", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "exitStatus": { + "anyOf": [ + { + "$ref": "#/$defs/TerminalExitStatus" + }, + { + "type": "null" + } + ], + "description": "Exit status if the command has completed." + }, + "output": { + "description": "The terminal output captured so far.", + "type": "string" + }, + "truncated": { + "description": "Whether the output was truncated due to byte limits.", + "type": "boolean" + } + }, + "required": ["output", "truncated"], + "type": "object", + "x-method": "terminal/output", + "x-side": "client" + }, + "TextContent": { + "description": "Text provided to or from an LLM.", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "annotations": { + "anyOf": [ + { + "$ref": "#/$defs/Annotations" + }, + { + "type": "null" + } + ] + }, + "text": { + "type": "string" + } + }, + "required": ["text"], + "type": "object" + }, + "TextResourceContents": { + "description": "Text-based resource contents.", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "mimeType": { + "type": ["string", "null"] + }, + "text": { + "type": "string" + }, + "uri": { + "type": "string" + } + }, + "required": ["text", "uri"], + "type": "object" + }, + "ToolCall": { + "description": "Represents a tool call that the language model has requested.\n\nTool calls are actions that the agent executes on behalf of the language model,\nsuch as reading files, executing code, or fetching data from external sources.\n\nSee protocol docs: [Tool Calls](https://agentclientprotocol.com/protocol/tool-calls)", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "content": { + "description": "Content produced by the tool call.", + "items": { + "$ref": "#/$defs/ToolCallContent" + }, + "type": "array" + }, + "kind": { + "allOf": [ + { + "$ref": "#/$defs/ToolKind" + } + ], + "description": "The category of tool being invoked.\nHelps clients choose appropriate icons and UI treatment." + }, + "locations": { + "description": "File locations affected by this tool call.\nEnables \"follow-along\" features in clients.", + "items": { + "$ref": "#/$defs/ToolCallLocation" + }, + "type": "array" + }, + "rawInput": { + "description": "Raw input parameters sent to the tool." + }, + "rawOutput": { + "description": "Raw output returned by the tool." + }, + "status": { + "allOf": [ + { + "$ref": "#/$defs/ToolCallStatus" + } + ], + "description": "Current execution status of the tool call." + }, + "title": { + "description": "Human-readable title describing what the tool is doing.", + "type": "string" + }, + "toolCallId": { + "allOf": [ + { + "$ref": "#/$defs/ToolCallId" + } + ], + "description": "Unique identifier for this tool call within the session." + } + }, + "required": ["toolCallId", "title"], + "type": "object" + }, + "ToolCallContent": { + "description": "Content produced by a tool call.\n\nTool calls can produce different types of content including\nstandard content blocks (text, images) or file diffs.\n\nSee protocol docs: [Content](https://agentclientprotocol.com/protocol/tool-calls#content)", + "discriminator": { + "propertyName": "type" + }, + "oneOf": [ + { + "allOf": [ + { + "$ref": "#/$defs/Content" + } + ], + "description": "Standard content block (text, images, resources).", + "properties": { + "type": { + "const": "content", + "type": "string" + } + }, + "required": ["type"], + "type": "object" + }, + { + "allOf": [ + { + "$ref": "#/$defs/Diff" + } + ], + "description": "File modification shown as a diff.", + "properties": { + "type": { + "const": "diff", + "type": "string" + } + }, + "required": ["type"], + "type": "object" + }, + { + "allOf": [ + { + "$ref": "#/$defs/Terminal" + } + ], + "description": "Embed a terminal created with `terminal/create` by its id.\n\nThe terminal must be added before calling `terminal/release`.\n\nSee protocol docs: [Terminal](https://agentclientprotocol.com/protocol/terminals)", + "properties": { + "type": { + "const": "terminal", + "type": "string" + } + }, + "required": ["type"], + "type": "object" + } + ] + }, + "ToolCallId": { + "description": "Unique identifier for a tool call within a session.", + "type": "string" + }, + "ToolCallLocation": { + "description": "A file location being accessed or modified by a tool.\n\nEnables clients to implement \"follow-along\" features that track\nwhich files the agent is working with in real-time.\n\nSee protocol docs: [Following the Agent](https://agentclientprotocol.com/protocol/tool-calls#following-the-agent)", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "line": { + "description": "Optional line number within the file.", + "format": "uint32", + "minimum": 0, + "type": ["integer", "null"] + }, + "path": { + "description": "The file path being accessed or modified.", + "type": "string" + } + }, + "required": ["path"], + "type": "object" + }, + "ToolCallStatus": { + "description": "Execution status of a tool call.\n\nTool calls progress through different statuses during their lifecycle.\n\nSee protocol docs: [Status](https://agentclientprotocol.com/protocol/tool-calls#status)", + "oneOf": [ + { + "const": "pending", + "description": "The tool call hasn't started running yet because the input is either\nstreaming or we're awaiting approval.", + "type": "string" + }, + { + "const": "in_progress", + "description": "The tool call is currently running.", + "type": "string" + }, + { + "const": "completed", + "description": "The tool call completed successfully.", + "type": "string" + }, + { + "const": "failed", + "description": "The tool call failed with an error.", + "type": "string" + } + ] + }, + "ToolCallUpdate": { + "description": "An update to an existing tool call.\n\nUsed to report progress and results as tools execute. All fields except\nthe tool call ID are optional - only changed fields need to be included.\n\nSee protocol docs: [Updating](https://agentclientprotocol.com/protocol/tool-calls#updating)", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "content": { + "description": "Replace the content collection.", + "items": { + "$ref": "#/$defs/ToolCallContent" + }, + "type": ["array", "null"] + }, + "kind": { + "anyOf": [ + { + "$ref": "#/$defs/ToolKind" + }, + { + "type": "null" + } + ], + "description": "Update the tool kind." + }, + "locations": { + "description": "Replace the locations collection.", + "items": { + "$ref": "#/$defs/ToolCallLocation" + }, + "type": ["array", "null"] + }, + "rawInput": { + "description": "Update the raw input." + }, + "rawOutput": { + "description": "Update the raw output." + }, + "status": { + "anyOf": [ + { + "$ref": "#/$defs/ToolCallStatus" + }, + { + "type": "null" + } + ], + "description": "Update the execution status." + }, + "title": { + "description": "Update the human-readable title.", + "type": ["string", "null"] + }, + "toolCallId": { + "allOf": [ + { + "$ref": "#/$defs/ToolCallId" + } + ], + "description": "The ID of the tool call being updated." + } + }, + "required": ["toolCallId"], + "type": "object" + }, + "ToolKind": { + "description": "Categories of tools that can be invoked.\n\nTool kinds help clients choose appropriate icons and optimize how they\ndisplay tool execution progress.\n\nSee protocol docs: [Creating](https://agentclientprotocol.com/protocol/tool-calls#creating)", + "oneOf": [ + { + "const": "read", + "description": "Reading files or data.", + "type": "string" + }, + { + "const": "edit", + "description": "Modifying files or content.", + "type": "string" + }, + { + "const": "delete", + "description": "Removing files or data.", + "type": "string" + }, + { + "const": "move", + "description": "Moving or renaming files.", + "type": "string" + }, + { + "const": "search", + "description": "Searching for information.", + "type": "string" + }, + { + "const": "execute", + "description": "Running commands or code.", + "type": "string" + }, + { + "const": "think", + "description": "Internal reasoning or planning.", + "type": "string" + }, + { + "const": "fetch", + "description": "Retrieving external data.", + "type": "string" + }, + { + "const": "switch_mode", + "description": "Switching the current session mode.", + "type": "string" + }, + { + "const": "other", + "description": "Other tool types (default).", + "type": "string" + } + ] + }, + "UnstructuredCommandInput": { + "description": "All text that was typed after the command name is provided as input.", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "hint": { + "description": "A hint to display when the input hasn't been provided yet", + "type": "string" + } + }, + "required": ["hint"], + "type": "object" + }, + "WaitForTerminalExitRequest": { + "description": "Request to wait for a terminal command to exit.", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "sessionId": { + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ], + "description": "The session ID for this request." + }, + "terminalId": { + "description": "The ID of the terminal to wait for.", + "type": "string" + } + }, + "required": ["sessionId", "terminalId"], + "type": "object", + "x-method": "terminal/wait_for_exit", + "x-side": "client" + }, + "WaitForTerminalExitResponse": { + "description": "Response containing the exit status of a terminal command.", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "exitCode": { + "description": "The process exit code (may be null if terminated by signal).", + "format": "uint32", + "minimum": 0, + "type": ["integer", "null"] + }, + "signal": { + "description": "The signal that terminated the process (may be null if exited normally).", + "type": ["string", "null"] + } + }, + "type": "object", + "x-method": "terminal/wait_for_exit", + "x-side": "client" + }, + "WriteTextFileRequest": { + "description": "Request to write content to a text file.\n\nOnly available if the client supports the `fs.writeTextFile` capability.", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "content": { + "description": "The text content to write to the file.", + "type": "string" + }, + "path": { + "description": "Absolute path to the file to write.", + "type": "string" + }, + "sessionId": { + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ], + "description": "The session ID for this request." + } + }, + "required": ["sessionId", "path", "content"], + "type": "object", + "x-method": "fs/write_text_file", + "x-side": "client" + }, + "WriteTextFileResponse": { + "description": "Response to `fs/write_text_file`", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + } + }, + "type": "object", + "x-method": "fs/write_text_file", + "x-side": "client" + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "anyOf": [ + { + "anyOf": [ + { + "allOf": [ + { + "$ref": "#/$defs/AgentRequest" + } + ], + "title": "Request" + }, + { + "allOf": [ + { + "$ref": "#/$defs/AgentResponse" + } + ], + "title": "Response" + }, + { + "allOf": [ + { + "$ref": "#/$defs/AgentNotification" + } + ], + "title": "Notification" + } + ], + "description": "A message (request, response, or notification) with `\"jsonrpc\": \"2.0\"` specified as\n[required by JSON-RPC 2.0 Specification][1].\n\n[1]: https://www.jsonrpc.org/specification#compatibility", + "properties": { + "jsonrpc": { + "enum": ["2.0"], + "type": "string" + } + }, + "required": ["jsonrpc"], + "title": "Agent", + "type": "object" + }, + { + "anyOf": [ + { + "allOf": [ + { + "$ref": "#/$defs/ClientRequest" + } + ], + "title": "Request" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ClientResponse" + } + ], + "title": "Response" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ClientNotification" + } + ], + "title": "Notification" + } + ], + "description": "A message (request, response, or notification) with `\"jsonrpc\": \"2.0\"` specified as\n[required by JSON-RPC 2.0 Specification][1].\n\n[1]: https://www.jsonrpc.org/specification#compatibility", + "properties": { + "jsonrpc": { + "enum": ["2.0"], + "type": "string" + } + }, + "required": ["jsonrpc"], + "title": "Client", + "type": "object" + } + ], + "title": "Agent Client Protocol" +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/session/Session.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/session/Session.java new file mode 100644 index 000000000..958d156be --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/session/Session.java @@ -0,0 +1,312 @@ +package com.alibaba.acp.sdk.session; + +import java.io.IOException; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; +import java.util.function.Consumer; +import java.util.function.Function; + +import com.alibaba.acp.sdk.protocol.agent.notification.SessionNotification; +import com.alibaba.acp.sdk.protocol.agent.request.ReadTextFileRequest; +import com.alibaba.acp.sdk.protocol.agent.request.RequestPermissionRequest; +import com.alibaba.acp.sdk.protocol.agent.request.WriteTextFileRequest; +import com.alibaba.acp.sdk.protocol.agent.request.terminal.CreateTerminalRequest; +import com.alibaba.acp.sdk.protocol.agent.request.terminal.KillTerminalCommandRequest; +import com.alibaba.acp.sdk.protocol.agent.request.terminal.ReleaseTerminalRequest; +import com.alibaba.acp.sdk.protocol.agent.request.terminal.TerminalOutputRequest; +import com.alibaba.acp.sdk.protocol.agent.request.terminal.WaitForTerminalExitRequest; +import com.alibaba.acp.sdk.protocol.agent.response.PromptResponse; +import com.alibaba.acp.sdk.protocol.client.notification.CancelNotification; +import com.alibaba.acp.sdk.protocol.client.request.LoadSessionRequest.LoadSessionRequestParams; +import com.alibaba.acp.sdk.protocol.client.request.PromptRequest; +import com.alibaba.acp.sdk.protocol.client.request.PromptRequest.PromptRequestParams; +import com.alibaba.acp.sdk.protocol.domain.content.block.ContentBlock; +import com.alibaba.acp.sdk.protocol.domain.session.update.AgentMessageChunkSessionUpdate; +import com.alibaba.acp.sdk.protocol.domain.session.update.AvailableCommandsUpdateSessionUpdate; +import com.alibaba.acp.sdk.protocol.domain.session.update.CurrentModeUpdateSessionUpdate; +import com.alibaba.acp.sdk.protocol.domain.session.update.PlanSessionUpdate; +import com.alibaba.acp.sdk.protocol.domain.session.update.SessionUpdate; +import com.alibaba.acp.sdk.protocol.domain.session.update.ToolCallSessionUpdate; +import com.alibaba.acp.sdk.protocol.domain.session.update.ToolCallUpdateSessionUpdate; +import com.alibaba.acp.sdk.protocol.jsonrpc.Error; +import com.alibaba.acp.sdk.protocol.jsonrpc.Message; +import com.alibaba.acp.sdk.protocol.jsonrpc.MethodMessage; +import com.alibaba.acp.sdk.protocol.jsonrpc.Request; +import com.alibaba.acp.sdk.protocol.jsonrpc.Response; +import com.alibaba.acp.sdk.session.event.consumer.AgentEventConsumer; +import com.alibaba.acp.sdk.session.event.consumer.ContentEventConsumer; +import com.alibaba.acp.sdk.session.event.consumer.FileEventConsumer; +import com.alibaba.acp.sdk.session.event.consumer.PermissionEventConsumer; +import com.alibaba.acp.sdk.session.event.consumer.PromptEndEventConsumer; +import com.alibaba.acp.sdk.session.event.consumer.TerminalEventConsumer; +import com.alibaba.acp.sdk.session.event.consumer.exception.EventConsumeException; +import com.alibaba.acp.sdk.transport.Transport; +import com.alibaba.acp.sdk.utils.MyConcurrentUtils; +import com.alibaba.acp.sdk.utils.Timeout; +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONObject; + +import org.apache.commons.lang3.Validate; +import org.apache.commons.lang3.exception.ContextedRuntimeException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import static com.alibaba.acp.sdk.protocol.jsonrpc.Error.ErrorCode.INTERNAL_ERROR; +import static com.alibaba.acp.sdk.utils.MyConcurrentUtils.runAndWait; + +/** + * Represents a session connection with an AI agent + * This class encapsulates all functionality for interacting with the agent, including sending prompts, handling various events and responses, etc. + * + * @author SkyFire + * @version 0.0.1 + */ +public class Session { + private final Transport transport; + private final LoadSessionRequestParams loadSessionRequestParams; + private static final Logger logger = LoggerFactory.getLogger(Session.class); + + /** + * Constructs a new session instance + * + * @param transport Transport instance for communication with the agent, cannot be null + * @param loadSessionRequestParams Load session request parameters, including session identifier and other information, cannot be null + */ + public Session(Transport transport, LoadSessionRequestParams loadSessionRequestParams) { + Validate.notNull(transport, "transport can't be null"); + this.transport = transport; + Validate.notNull(loadSessionRequestParams, "loadSessionRequestParams can't be null"); + this.loadSessionRequestParams = loadSessionRequestParams; + } + + /** + * Cancels the currently ongoing operation + * Sends a cancellation notification to the agent, requesting termination of the currently executing task. + * + * @throws IOException Thrown when an IO error occurs while sending the cancellation notification + */ + public void cancel() throws IOException { + CancelNotification cancelNotification = new CancelNotification(); + String message = cancelNotification.toString(); + logger.debug("send_cancelNotification to agent {}", message); + transport.inputNoWaitResponse(message); + } + + /** + * Sends a prompt message to the agent and handles the response + * This method sends a prompt to the agent and handles various responses and events from the agent, including content updates, terminal operations, permission requests, etc. + * + * @param prompts List of prompt content blocks, cannot be empty + * @param agentEventConsumer Consumer for handling agent events, cannot be null + * @throws IOException Thrown when an IO error occurs while sending the prompt or receiving the response + */ + public void sendPrompt(List prompts, AgentEventConsumer agentEventConsumer) throws IOException { + Validate.notEmpty(prompts, "prompts can't be empty"); + PromptRequest promptRequest = new PromptRequest(new PromptRequestParams(loadSessionRequestParams.getSessionId(), prompts)); + + Validate.notNull(agentEventConsumer, "agentEventConsumer can't be null"); + ContentEventConsumer contentEventConsumer = agentEventConsumer.getContentEventConsumer(); + PromptEndEventConsumer promptEndEventConsumer = agentEventConsumer.getPromptEndEventConsumer(); + TerminalEventConsumer terminalEventConsumer = agentEventConsumer.getTerminalEventConsumer(); + PermissionEventConsumer permissionEventConsumer = agentEventConsumer.getPermissionEventConsumer(); + FileEventConsumer fileEventConsumer = agentEventConsumer.getFileEventConsumer(); + + String requestMessage = promptRequest.toString(); + logger.debug("send_prompt to agent: {}", requestMessage); + transport.inputWaitForMultiLine(requestMessage, (String line) -> { + logger.debug("received_message from agent: {}", line); + if (line == null) { + return true; + } + Message message = this.toMessage(line); + if (message instanceof PromptResponse) { + logger.debug("rcv prompt_turn_end for prompt {}", prompts); + if (promptEndEventConsumer != null) { + processNoWaitResponse(promptEndEventConsumer::onPromptEnd, promptEndEventConsumer::onPromptEndTimeout, + ((PromptResponse) message).getResult()); + } + return true; + } else if (message instanceof SessionNotification) { + if (contentEventConsumer != null) { + processSessionUpdate(contentEventConsumer, ((SessionNotification) message).getParams().getUpdate()); + } + return false; + } else if (message instanceof RequestPermissionRequest) { + if (permissionEventConsumer != null) { + processRequest(permissionEventConsumer::onRequestPermissionRequest, permissionEventConsumer::onRequestPermissionRequestTimeout, + (RequestPermissionRequest) message); + } + return false; + } else if (message instanceof ReadTextFileRequest) { + if (fileEventConsumer != null) { + processRequest(fileEventConsumer::onReadTextFileRequest, fileEventConsumer::onReadTextFileRequestTimeout, + (ReadTextFileRequest) message); + } + return false; + } else if (message instanceof WriteTextFileRequest) { + if (fileEventConsumer != null) { + processRequest(fileEventConsumer::onWriteTextFileRequest, fileEventConsumer::onWriteTextFileRequestTimeout, + (WriteTextFileRequest) message); + } + return false; + } else if (message instanceof CreateTerminalRequest) { + if (terminalEventConsumer != null) { + processRequest(terminalEventConsumer::onCreateTerminalRequest, terminalEventConsumer::onCreateTerminalRequestTimeout, + (CreateTerminalRequest) message); + } + return false; + } else if (message instanceof ReleaseTerminalRequest) { + if (terminalEventConsumer != null) { + processRequest(terminalEventConsumer::onReleaseTerminalRequest, terminalEventConsumer::onReleaseTerminalRequestTimeout, + (ReleaseTerminalRequest) message); + } + return false; + } else if (message instanceof WaitForTerminalExitRequest) { + if (terminalEventConsumer != null) { + processRequest(terminalEventConsumer::onWaitForTerminalExitRequest, terminalEventConsumer::onWaitForTerminalExitRequestTimeout, + (WaitForTerminalExitRequest) message); + } + return false; + } else if (message instanceof TerminalOutputRequest) { + if (terminalEventConsumer != null) { + processRequest(terminalEventConsumer::onTerminalOutput, terminalEventConsumer::onTerminalOutputRequestTimeout, + (TerminalOutputRequest) message); + } + return false; + } else if (message instanceof KillTerminalCommandRequest) { + if (terminalEventConsumer != null) { + processRequest(terminalEventConsumer::onKillTerminalCommandRequest, terminalEventConsumer::onKillTerminalCommandRequestTimeout, + (KillTerminalCommandRequest) message); + } + return false; + } else { + logger.warn("Unknown message, will end prompt turn. {}", line); + return false; + } + }); + } + + /** + * Parses a string into the corresponding message object + *
+ * Determines the message type based on the fields in the JSON content and converts it to the corresponding object. + * + * @param line String containing JSON-formatted message + * @return Parsed message object + */ + private Message toMessage(String line) { + JSONObject jsonObject = JSON.parseObject(line); + if (jsonObject.containsKey("method")) { + return jsonObject.toJavaObject(MethodMessage.class); + } else if (jsonObject.containsKey("result") || jsonObject.containsKey("error")) { + JSONObject result = jsonObject.getJSONObject("result"); + if (result != null && result.containsKey("stopReason")) { + return jsonObject.toJavaObject(PromptResponse.class); + } else { + return jsonObject.toJavaObject(Response.class); + } + } else { + return jsonObject.toJavaObject(Message.class); + } + } + + /** + * Processes session update events + *
+ * Calls the corresponding content event handler based on different session update types. + * + * @param contentEventConsumer Content event consumer + * @param sessionUpdate Session update object + */ + private void processSessionUpdate(ContentEventConsumer contentEventConsumer, SessionUpdate sessionUpdate) { + if (contentEventConsumer == null) { + return; + } + if (sessionUpdate instanceof AgentMessageChunkSessionUpdate) { + processNoWaitResponse(contentEventConsumer::onAgentMessageChunkSessionUpdate, + contentEventConsumer::onAgentMessageChunkSessionUpdateTimeout, (AgentMessageChunkSessionUpdate) sessionUpdate); + } else if (sessionUpdate instanceof ToolCallUpdateSessionUpdate) { + processNoWaitResponse(contentEventConsumer::onToolCallUpdateSessionUpdate, contentEventConsumer::onToolCallUpdateSessionUpdateTimeout, + (ToolCallUpdateSessionUpdate) sessionUpdate); + } else if (sessionUpdate instanceof ToolCallSessionUpdate) { + processNoWaitResponse(contentEventConsumer::onToolCallSessionUpdate, contentEventConsumer::onToolCallSessionUpdateTimeout, + (ToolCallSessionUpdate) sessionUpdate); + } else if (sessionUpdate instanceof AvailableCommandsUpdateSessionUpdate) { + processNoWaitResponse(contentEventConsumer::onAvailableCommandsUpdateSessionUpdate, + contentEventConsumer::onAvailableCommandsUpdateSessionUpdateTimeout, + (AvailableCommandsUpdateSessionUpdate) sessionUpdate); + } else if (sessionUpdate instanceof CurrentModeUpdateSessionUpdate) { + processNoWaitResponse(contentEventConsumer::onCurrentModeUpdateSessionUpdate, + contentEventConsumer::onCurrentModeUpdateSessionUpdateTimeout, + (CurrentModeUpdateSessionUpdate) sessionUpdate); + } else if (sessionUpdate instanceof PlanSessionUpdate) { + processNoWaitResponse(contentEventConsumer::onPlanSessionUpdate, contentEventConsumer::onPlanSessionUpdateTimeout, + (PlanSessionUpdate) sessionUpdate); + } + } + + /** + * Processes event consumption without waiting for a response + *
+ * Executes event consumption logic within the specified timeout period. + * + * @param consumer Event consumer + * @param timeoutFunction Timeout function + * @param notification Notification object + * @param Notification type + */ + private void processNoWaitResponse(Consumer consumer, Function timeoutFunction, N notification) { + runAndWait(() -> consumer.accept(notification), Optional.ofNullable(timeoutFunction) + .map(tf -> tf.apply(notification)) + .orElse(defaultEventConsumeTimeout)); + } + + /** + * Processes requests that require a response + *
+ * Executes request processing logic and sends a response to the agent upon completion. + * + * @param requestProcessor Request processor + * @param timeoutFunction Timeout function + * @param request Request object + * @param Request type + * @param Response payload type + */ + private , L> void processRequest(Function requestProcessor, Function timeoutFunction, R request) { + Response response = new Response<>(); + response.setId(request.getId()); + try { + L result = MyConcurrentUtils.runAndWait( + () -> requestProcessor.apply(request), + Optional.ofNullable(timeoutFunction) + .map(tf -> tf.apply(request)) + .orElse(defaultEventConsumeTimeout)); + response.setResult(result); + } catch (EventConsumeException eventConsumeException) { + response.setError(eventConsumeException.getError()); + } catch (ExecutionException e) { + if (e.getCause() instanceof EventConsumeException) { + response.setError(((EventConsumeException) e.getCause()).getError()); + } else { + response.setError(new Error(INTERNAL_ERROR.getCode(), INTERNAL_ERROR.getDescription(), e.getMessage())); + } + } catch (InterruptedException | TimeoutException e) { + response.setError(new Error(INTERNAL_ERROR.getCode(), INTERNAL_ERROR.getDescription(), e.getMessage())); + } + try { + String message = response.toString(); + logger.debug("send_response to agent: {}", message); + transport.inputNoWaitResponse(message); + } catch (Exception e) { + throw new ContextedRuntimeException("Failed to send response by transport ", e) + .addContextValue("transport", transport) + .addContextValue("request", request) + .addContextValue("response", response); + } + } + + /** Default event consumption timeout is 60 seconds */ + Timeout defaultEventConsumeTimeout = Timeout.TIMEOUT_60_SECONDS; +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/session/event/consumer/AgentEventConsumer.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/session/event/consumer/AgentEventConsumer.java new file mode 100644 index 000000000..c004366d0 --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/session/event/consumer/AgentEventConsumer.java @@ -0,0 +1,118 @@ +package com.alibaba.acp.sdk.session.event.consumer; + +/** + * Agent Event Consumer Container + * + * This class serves as a container for various event consumers that handle different types of events + * received from the AI agent during a session, such as content updates, file operations, terminal commands, etc. + * + * @author SkyFire + * @version 0.0.1 + */ +public class AgentEventConsumer { + private ContentEventConsumer contentEventConsumer; + private FileEventConsumer fileEventConsumer; + private TerminalEventConsumer terminalEventConsumer; + private PermissionEventConsumer permissionEventConsumer; + private PromptEndEventConsumer promptEndEventConsumer; + + /** + * Gets the content event consumer + * + * @return ContentEventConsumer instance for handling content-related events + */ + public ContentEventConsumer getContentEventConsumer() { + return contentEventConsumer; + } + + /** + * Sets the content event consumer + * + * @param contentEventConsumer ContentEventConsumer instance for handling content-related events + * @return Current instance for method chaining + */ + public AgentEventConsumer setContentEventConsumer(ContentEventConsumer contentEventConsumer) { + this.contentEventConsumer = contentEventConsumer; + return this; + } + + /** + * Gets the file event consumer + * + * @return FileEventConsumer instance for handling file-related events + */ + public FileEventConsumer getFileEventConsumer() { + return fileEventConsumer; + } + + /** + * Sets the file event consumer + * + * @param fileEventConsumer FileEventConsumer instance for handling file-related events + * @return Current instance for method chaining + */ + public AgentEventConsumer setFileEventConsumer(FileEventConsumer fileEventConsumer) { + this.fileEventConsumer = fileEventConsumer; + return this; + } + + /** + * Gets the terminal event consumer + * + * @return TerminalEventConsumer instance for handling terminal-related events + */ + public TerminalEventConsumer getTerminalEventConsumer() { + return terminalEventConsumer; + } + + /** + * Sets the terminal event consumer + * + * @param terminalEventConsumer TerminalEventConsumer instance for handling terminal-related events + * @return Current instance for method chaining + */ + public AgentEventConsumer setTerminalEventConsumer(TerminalEventConsumer terminalEventConsumer) { + this.terminalEventConsumer = terminalEventConsumer; + return this; + } + + /** + * Gets the permission event consumer + * + * @return PermissionEventConsumer instance for handling permission-related events + */ + public PermissionEventConsumer getPermissionEventConsumer() { + return permissionEventConsumer; + } + + /** + * Sets the permission event consumer + * + * @param permissionEventConsumer PermissionEventConsumer instance for handling permission-related events + * @return Current instance for method chaining + */ + public AgentEventConsumer setPermissionEventConsumer(PermissionEventConsumer permissionEventConsumer) { + this.permissionEventConsumer = permissionEventConsumer; + return this; + } + + /** + * Gets the prompt end event consumer + * + * @return PromptEndEventConsumer instance for handling prompt completion events + */ + public PromptEndEventConsumer getPromptEndEventConsumer() { + return promptEndEventConsumer; + } + + /** + * Sets the prompt end event consumer + * + * @param promptEndEventConsumer PromptEndEventConsumer instance for handling prompt completion events + * @return Current instance for method chaining + */ + public AgentEventConsumer setPromptEndEventConsumer(PromptEndEventConsumer promptEndEventConsumer) { + this.promptEndEventConsumer = promptEndEventConsumer; + return this; + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/session/event/consumer/ContentEventConsumer.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/session/event/consumer/ContentEventConsumer.java new file mode 100644 index 000000000..8656f78cd --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/session/event/consumer/ContentEventConsumer.java @@ -0,0 +1,110 @@ +package com.alibaba.acp.sdk.session.event.consumer; + +import com.alibaba.acp.sdk.protocol.domain.session.update.AgentMessageChunkSessionUpdate; +import com.alibaba.acp.sdk.protocol.domain.session.update.AvailableCommandsUpdateSessionUpdate; +import com.alibaba.acp.sdk.protocol.domain.session.update.CurrentModeUpdateSessionUpdate; +import com.alibaba.acp.sdk.protocol.domain.session.update.PlanSessionUpdate; +import com.alibaba.acp.sdk.protocol.domain.session.update.ToolCallSessionUpdate; +import com.alibaba.acp.sdk.protocol.domain.session.update.ToolCallUpdateSessionUpdate; +import com.alibaba.acp.sdk.utils.Timeout; + +/** + * Content Event Consumer Interface + * + * This interface defines methods for handling content-related events received from the AI agent, + * such as message chunks, available commands, mode updates, plans, and tool calls. + * + * @author SkyFire + * @version 0.0.1 + */ +public interface ContentEventConsumer { + /** + * Handles agent message chunk session updates + * + * @param sessionUpdate Agent message chunk session update + */ + void onAgentMessageChunkSessionUpdate(AgentMessageChunkSessionUpdate sessionUpdate); + + /** + * Handles available commands update session updates + * + * @param sessionUpdate Available commands update session update + */ + void onAvailableCommandsUpdateSessionUpdate(AvailableCommandsUpdateSessionUpdate sessionUpdate); + + /** + * Handles current mode update session updates + * + * @param sessionUpdate Current mode update session update + */ + void onCurrentModeUpdateSessionUpdate(CurrentModeUpdateSessionUpdate sessionUpdate); + + /** + * Handles plan session updates + * + * @param sessionUpdate Plan session update + */ + void onPlanSessionUpdate(PlanSessionUpdate sessionUpdate); + + /** + * Handles tool call update session updates + * + * @param sessionUpdate Tool call update session update + */ + void onToolCallUpdateSessionUpdate(ToolCallUpdateSessionUpdate sessionUpdate); + + /** + * Handles tool call session updates + * + * @param sessionUpdate Tool call session update + */ + void onToolCallSessionUpdate(ToolCallSessionUpdate sessionUpdate); + + /** + * Gets timeout for agent message chunk session update processing + * + * @param sessionUpdate Agent message chunk session update + * @return Timeout for processing the update + */ + Timeout onAgentMessageChunkSessionUpdateTimeout(AgentMessageChunkSessionUpdate sessionUpdate); + + /** + * Gets timeout for available commands update session update processing + * + * @param sessionUpdate Available commands update session update + * @return Timeout for processing the update + */ + Timeout onAvailableCommandsUpdateSessionUpdateTimeout(AvailableCommandsUpdateSessionUpdate sessionUpdate); + + /** + * Gets timeout for current mode update session update processing + * + * @param sessionUpdate Current mode update session update + * @return Timeout for processing the update + */ + Timeout onCurrentModeUpdateSessionUpdateTimeout(CurrentModeUpdateSessionUpdate sessionUpdate); + + /** + * Gets timeout for plan session update processing + * + * @param sessionUpdate Plan session update + * @return Timeout for processing the update + */ + Timeout onPlanSessionUpdateTimeout(PlanSessionUpdate sessionUpdate); + + /** + * Gets timeout for tool call update session update processing + * + * @param sessionUpdate Tool call update session update + * @return Timeout for processing the update + */ + Timeout onToolCallUpdateSessionUpdateTimeout(ToolCallUpdateSessionUpdate sessionUpdate); + + /** + * Gets timeout for tool call session update processing + * + * @param sessionUpdate Tool call session update + * @return Timeout for processing the update + */ + Timeout onToolCallSessionUpdateTimeout(ToolCallSessionUpdate sessionUpdate); +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/session/event/consumer/ContentEventSimpleConsumer.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/session/event/consumer/ContentEventSimpleConsumer.java new file mode 100644 index 000000000..896e667b0 --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/session/event/consumer/ContentEventSimpleConsumer.java @@ -0,0 +1,84 @@ +package com.alibaba.acp.sdk.session.event.consumer; + +import com.alibaba.acp.sdk.protocol.domain.session.update.AgentMessageChunkSessionUpdate; +import com.alibaba.acp.sdk.protocol.domain.session.update.AvailableCommandsUpdateSessionUpdate; +import com.alibaba.acp.sdk.protocol.domain.session.update.CurrentModeUpdateSessionUpdate; +import com.alibaba.acp.sdk.protocol.domain.session.update.PlanSessionUpdate; +import com.alibaba.acp.sdk.protocol.domain.session.update.ToolCallSessionUpdate; +import com.alibaba.acp.sdk.protocol.domain.session.update.ToolCallUpdateSessionUpdate; +import com.alibaba.acp.sdk.utils.Timeout; + +/** + * Simple Content Event Consumer Implementation + * + * This class provides a simple implementation of the ContentEventConsumer interface + * that performs minimal processing for content-related events, primarily serving as a base + * implementation that can be extended or used as-is for basic functionality. + * + * @author SkyFire + * @version 0.0.1 + */ +public class ContentEventSimpleConsumer implements ContentEventConsumer { + @Override + public void onAgentMessageChunkSessionUpdate(AgentMessageChunkSessionUpdate sessionUpdate) { + // Simple implementation - does nothing + } + + @Override + public void onAvailableCommandsUpdateSessionUpdate(AvailableCommandsUpdateSessionUpdate sessionUpdate) { + // Simple implementation - does nothing + } + + @Override + public void onCurrentModeUpdateSessionUpdate(CurrentModeUpdateSessionUpdate sessionUpdate) { + // Simple implementation - does nothing + } + + @Override + public void onPlanSessionUpdate(PlanSessionUpdate sessionUpdate) { + // Simple implementation - does nothing + } + + @Override + public void onToolCallUpdateSessionUpdate(ToolCallUpdateSessionUpdate sessionUpdate) { + // Simple implementation - does nothing + } + + @Override + public void onToolCallSessionUpdate(ToolCallSessionUpdate sessionUpdate) { + // Simple implementation - does nothing + } + + @Override + public Timeout onAgentMessageChunkSessionUpdateTimeout(AgentMessageChunkSessionUpdate sessionUpdate) { + return defaultTimeout; + } + + @Override + public Timeout onAvailableCommandsUpdateSessionUpdateTimeout(AvailableCommandsUpdateSessionUpdate sessionUpdate) { + return defaultTimeout; + } + + @Override + public Timeout onCurrentModeUpdateSessionUpdateTimeout(CurrentModeUpdateSessionUpdate sessionUpdate) { + return defaultTimeout; + } + + @Override + public Timeout onPlanSessionUpdateTimeout(PlanSessionUpdate sessionUpdate) { + return defaultTimeout; + } + + @Override + public Timeout onToolCallUpdateSessionUpdateTimeout(ToolCallUpdateSessionUpdate sessionUpdate) { + return defaultTimeout; + } + + @Override + public Timeout onToolCallSessionUpdateTimeout(ToolCallSessionUpdate sessionUpdate) { + return defaultTimeout; + } + + /** Default timeout for event processing is 60 seconds */ + Timeout defaultTimeout = Timeout.TIMEOUT_60_SECONDS; +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/session/event/consumer/FileEventConsumer.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/session/event/consumer/FileEventConsumer.java new file mode 100644 index 000000000..6765fc4d1 --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/session/event/consumer/FileEventConsumer.java @@ -0,0 +1,53 @@ +package com.alibaba.acp.sdk.session.event.consumer; + +import com.alibaba.acp.sdk.protocol.agent.request.ReadTextFileRequest; +import com.alibaba.acp.sdk.protocol.agent.request.WriteTextFileRequest; +import com.alibaba.acp.sdk.protocol.client.response.ReadTextFileResponse.ReadTextFileResponseResult; +import com.alibaba.acp.sdk.protocol.client.response.WriteTextFileResponse.WriteTextFileResponseResult; +import com.alibaba.acp.sdk.session.event.consumer.exception.EventConsumeException; +import com.alibaba.acp.sdk.utils.Timeout; + +/** + * File Event Consumer Interface + * + * This interface defines methods for handling file-related events received from the AI agent, + * such as reading and writing text files. + * + * @author SkyFire + * @version 0.0.1 + */ +public interface FileEventConsumer { + /** + * Handles read text file requests from the agent + * + * @param request Read text file request from the agent + * @return Result of reading the file + * @throws EventConsumeException Thrown when an error occurs during event processing + */ + ReadTextFileResponseResult onReadTextFileRequest(ReadTextFileRequest request) throws EventConsumeException; + + /** + * Handles write text file requests from the agent + * + * @param request Write text file request from the agent + * @return Result of writing the file + * @throws EventConsumeException Thrown when an error occurs during event processing + */ + WriteTextFileResponseResult onWriteTextFileRequest(WriteTextFileRequest request) throws EventConsumeException; + + /** + * Gets timeout for read text file request processing + * + * @param message Read text file request message + * @return Timeout for processing the request + */ + Timeout onReadTextFileRequestTimeout(ReadTextFileRequest message); + + /** + * Gets timeout for write text file request processing + * + * @param message Write text file request message + * @return Timeout for processing the request + */ + Timeout onWriteTextFileRequestTimeout(WriteTextFileRequest message); +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/session/event/consumer/FileEventSimpleConsumer.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/session/event/consumer/FileEventSimpleConsumer.java new file mode 100644 index 000000000..ef8a7b50e --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/session/event/consumer/FileEventSimpleConsumer.java @@ -0,0 +1,168 @@ +package com.alibaba.acp.sdk.session.event.consumer; + +import java.io.BufferedReader; +import java.io.File; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.Optional; + +import com.alibaba.acp.sdk.protocol.agent.request.ReadTextFileRequest; +import com.alibaba.acp.sdk.protocol.agent.request.ReadTextFileRequest.ReadTextFileRequestParams; +import com.alibaba.acp.sdk.protocol.agent.request.WriteTextFileRequest; +import com.alibaba.acp.sdk.protocol.client.response.ReadTextFileResponse.ReadTextFileResponseResult; +import com.alibaba.acp.sdk.protocol.client.response.WriteTextFileResponse.WriteTextFileResponseResult; +import com.alibaba.acp.sdk.session.event.consumer.exception.EventConsumeException; +import com.alibaba.acp.sdk.utils.Timeout; + +import org.apache.commons.io.FileUtils; +import org.apache.commons.lang3.StringUtils; + +import static com.alibaba.acp.sdk.protocol.jsonrpc.Error.ErrorCode.INTERNAL_ERROR; +import static com.alibaba.acp.sdk.protocol.jsonrpc.Error.ErrorCode.INVALID_PARAMS; +import static com.alibaba.acp.sdk.protocol.jsonrpc.Error.ErrorCode.RESOURCE_NOT_FOUND; + +/** + * Simple File Event Consumer Implementation + * + * This class provides a simple implementation of the FileEventConsumer interface + * that handles file read and write operations. It supports reading text files with optional + * line range parameters and writing content to text files. + * + * @author SkyFire + * @version 0.0.1 + */ +public class FileEventSimpleConsumer implements FileEventConsumer { + @Override + public ReadTextFileResponseResult onReadTextFileRequest(ReadTextFileRequest request) throws EventConsumeException { + ReadTextFileResponseResult result = new ReadTextFileResponseResult(); + if (request == null) { + return result; + } + String filePath = request.getParams().getPath(); + if (StringUtils.isBlank(filePath)) { + return result; + } + File file = new File(filePath); + if (!file.exists()) { + throw new EventConsumeException().setError(RESOURCE_NOT_FOUND.getCode(), RESOURCE_NOT_FOUND.getDescription(), filePath); + } + int startLine = Optional.ofNullable(request.getParams()) + .map(ReadTextFileRequestParams::getLine) + .orElse(1); + int limit = Optional.ofNullable(request.getParams()).map(ReadTextFileRequestParams::getLimit).orElse(1000); + try { + StringBuilder content = new StringBuilder(); + try (BufferedReader br = new BufferedReader(new InputStreamReader(Files.newInputStream(file.toPath()), charset), 16384)) { + for (int i = 1; i < startLine; i++) { + if (br.readLine() == null) { + return result; + } + } + String line; + int count = 0; + while (count < limit && (line = br.readLine()) != null) { + content.append(line).append(File.separator); + count++; + } + } + result.setContent(content.toString()); + return result; + } catch (IOException e) { + throw new EventConsumeException("Failed to read file", e) + .setError(INTERNAL_ERROR.getCode(), INTERNAL_ERROR.getDescription(), filePath) + .addContextValue("filePath", filePath).addContextValue("startLine", startLine).addContextValue("limit", limit); + } + } + + @Override + public WriteTextFileResponseResult onWriteTextFileRequest(WriteTextFileRequest request) throws EventConsumeException { + if (request == null) { + throw new EventConsumeException().setError(INVALID_PARAMS.getCode(), INVALID_PARAMS.getDescription(), "writeTextFileRequest can't be null"); + } + String filePath = request.getParams().getPath(); + if (StringUtils.isBlank(filePath)) { + throw new EventConsumeException().setError(INVALID_PARAMS.getCode(), INVALID_PARAMS.getDescription(), "the path of writeTextFileRequest can't be null"); + } + File file = new File(filePath); + String content = request.getParams().getContent(); + try { + FileUtils.write(file, content, charset); + } catch (IOException e) { + throw new EventConsumeException("Failed to write file", e) + .setError(INTERNAL_ERROR.getCode(), INTERNAL_ERROR.getDescription(), filePath) + .addContextValue("filePath", filePath); + } + return new WriteTextFileResponseResult(); + } + + @Override + public Timeout onReadTextFileRequestTimeout(ReadTextFileRequest message) { + return readTimeout; + } + + @Override + public Timeout onWriteTextFileRequestTimeout(WriteTextFileRequest message) { + return writeTimeout; + } + + private Charset charset = StandardCharsets.UTF_8; + private Timeout readTimeout = Timeout.TIMEOUT_3_SECONDS; + private Timeout writeTimeout = Timeout.TIMEOUT_3_SECONDS; + + /** + * Gets the character set used for file operations + * + * @return The character set used for file operations + */ + public Charset getCharset() { + return charset; + } + + /** + * Sets the character set used for file operations + * + * @param charset The character set to use for file operations + */ + public void setCharset(Charset charset) { + this.charset = charset; + } + + /** + * Gets the timeout for read file operations + * + * @return The timeout for read file operations + */ + public Timeout getReadTimeout() { + return readTimeout; + } + + /** + * Sets the timeout for read file operations + * + * @param readTimeout The timeout for read file operations + */ + public void setReadTimeout(Timeout readTimeout) { + this.readTimeout = readTimeout; + } + + /** + * Gets the timeout for write file operations + * + * @return The timeout for write file operations + */ + public Timeout getWriteTimeout() { + return writeTimeout; + } + + /** + * Sets the timeout for write file operations + * + * @param writeTimeout The timeout for write file operations + */ + public void setWriteTimeout(Timeout writeTimeout) { + this.writeTimeout = writeTimeout; + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/session/event/consumer/PermissionEventConsumer.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/session/event/consumer/PermissionEventConsumer.java new file mode 100644 index 000000000..0bda5085b --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/session/event/consumer/PermissionEventConsumer.java @@ -0,0 +1,34 @@ +package com.alibaba.acp.sdk.session.event.consumer; + +import com.alibaba.acp.sdk.protocol.agent.request.RequestPermissionRequest; +import com.alibaba.acp.sdk.protocol.client.response.RequestPermissionResponse.RequestPermissionResponseResult; +import com.alibaba.acp.sdk.session.event.consumer.exception.EventConsumeException; +import com.alibaba.acp.sdk.utils.Timeout; + +/** + * Permission Event Consumer Interface + * + * This interface defines methods for handling permission-related events received from the AI agent, + * such as permission requests. + * + * @author SkyFire + * @version 0.0.1 + */ +public interface PermissionEventConsumer { + /** + * Handles permission requests from the agent + * + * @param request Permission request from the agent + * @return Result of processing the permission request + * @throws EventConsumeException Thrown when an error occurs during event processing + */ + RequestPermissionResponseResult onRequestPermissionRequest(RequestPermissionRequest request) throws EventConsumeException; + + /** + * Gets timeout for permission request processing + * + * @param request Permission request + * @return Timeout for processing the request + */ + Timeout onRequestPermissionRequestTimeout(RequestPermissionRequest request); +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/session/event/consumer/PromptEndEventConsumer.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/session/event/consumer/PromptEndEventConsumer.java new file mode 100644 index 000000000..eaf339810 --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/session/event/consumer/PromptEndEventConsumer.java @@ -0,0 +1,29 @@ +package com.alibaba.acp.sdk.session.event.consumer; + +import com.alibaba.acp.sdk.protocol.agent.response.PromptResponse.PromptResponseResult; +import com.alibaba.acp.sdk.utils.Timeout; + +/** + * Prompt End Event Consumer Interface + * + * This interface defines methods for handling prompt completion events received from the AI agent. + * + * @author SkyFire + * @version 0.0.1 + */ +public interface PromptEndEventConsumer { + /** + * Handles prompt end events from the agent + * + * @param promptResponseResult Prompt response result indicating the end of a prompt + */ + void onPromptEnd(PromptResponseResult promptResponseResult); + + /** + * Gets timeout for prompt end event processing + * + * @param promptResponseResult Prompt response result + * @return Timeout for processing the event + */ + Timeout onPromptEndTimeout(PromptResponseResult promptResponseResult); +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/session/event/consumer/TerminalEventConsumer.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/session/event/consumer/TerminalEventConsumer.java new file mode 100644 index 000000000..016324943 --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/session/event/consumer/TerminalEventConsumer.java @@ -0,0 +1,110 @@ +package com.alibaba.acp.sdk.session.event.consumer; + +import com.alibaba.acp.sdk.protocol.agent.request.terminal.CreateTerminalRequest; +import com.alibaba.acp.sdk.protocol.agent.request.terminal.KillTerminalCommandRequest; +import com.alibaba.acp.sdk.protocol.agent.request.terminal.ReleaseTerminalRequest; +import com.alibaba.acp.sdk.protocol.agent.request.terminal.TerminalOutputRequest; +import com.alibaba.acp.sdk.protocol.agent.request.terminal.WaitForTerminalExitRequest; +import com.alibaba.acp.sdk.protocol.client.response.terminal.CreateTerminalResponse.CreateTerminalResponseResult; +import com.alibaba.acp.sdk.protocol.client.response.terminal.KillTerminalCommandResponse.KillTerminalCommandResponseResult; +import com.alibaba.acp.sdk.protocol.client.response.terminal.ReleaseTerminalResponse.ReleaseTerminalResponseResult; +import com.alibaba.acp.sdk.protocol.client.response.terminal.TerminalOutputResponse.TerminalOutputResponseResult; +import com.alibaba.acp.sdk.protocol.client.response.terminal.WaitForTerminalExitResponse.WaitForTerminalExitResponseResult; +import com.alibaba.acp.sdk.session.event.consumer.exception.EventConsumeException; +import com.alibaba.acp.sdk.utils.Timeout; + +/** + * Terminal Event Consumer Interface + * + * This interface defines methods for handling terminal-related events received from the AI agent, + * such as creating terminals, waiting for terminal exit, releasing terminals, handling terminal output, and killing terminal commands. + * + * @author SkyFire + * @version 0.0.1 + */ +public interface TerminalEventConsumer { + /** + * Handles create terminal requests from the agent + * + * @param request Create terminal request from the agent + * @return Result of creating the terminal + * @throws EventConsumeException Thrown when an error occurs during event processing + */ + CreateTerminalResponseResult onCreateTerminalRequest(CreateTerminalRequest request) throws EventConsumeException; + + /** + * Handles wait for terminal exit requests from the agent + * + * @param request Wait for terminal exit request from the agent + * @return Result of waiting for terminal exit + * @throws EventConsumeException Thrown when an error occurs during event processing + */ + WaitForTerminalExitResponseResult onWaitForTerminalExitRequest(WaitForTerminalExitRequest request) throws EventConsumeException; + + /** + * Handles release terminal requests from the agent + * + * @param request Release terminal request from the agent + * @return Result of releasing the terminal + * @throws EventConsumeException Thrown when an error occurs during event processing + */ + ReleaseTerminalResponseResult onReleaseTerminalRequest(ReleaseTerminalRequest request) throws EventConsumeException; + + /** + * Handles terminal output requests from the agent + * + * @param request Terminal output request from the agent + * @return Result of processing terminal output + * @throws EventConsumeException Thrown when an error occurs during event processing + */ + TerminalOutputResponseResult onTerminalOutput(TerminalOutputRequest request) throws EventConsumeException; + + /** + * Handles kill terminal command requests from the agent + * + * @param request Kill terminal command request from the agent + * @return Result of killing the terminal command + * @throws EventConsumeException Thrown when an error occurs during event processing + */ + KillTerminalCommandResponseResult onKillTerminalCommandRequest(KillTerminalCommandRequest request) throws EventConsumeException; + + /** + * Gets timeout for create terminal request processing + * + * @param createTerminalRequest Create terminal request + * @return Timeout for processing the request + */ + Timeout onCreateTerminalRequestTimeout(CreateTerminalRequest createTerminalRequest); + + /** + * Gets timeout for wait for terminal exit request processing + * + * @param waitForTerminalExitRequest Wait for terminal exit request + * @return Timeout for processing the request + */ + Timeout onWaitForTerminalExitRequestTimeout(WaitForTerminalExitRequest waitForTerminalExitRequest); + + /** + * Gets timeout for release terminal request processing + * + * @param releaseTerminalRequest Release terminal request + * @return Timeout for processing the request + */ + Timeout onReleaseTerminalRequestTimeout(ReleaseTerminalRequest releaseTerminalRequest); + + /** + * Gets timeout for terminal output request processing + * + * @param terminalOutputRequest Terminal output request + * @return Timeout for processing the request + */ + Timeout onTerminalOutputRequestTimeout(TerminalOutputRequest terminalOutputRequest); + + /** + * Gets timeout for kill terminal command request processing + * + * @param killTerminalCommandRequest Kill terminal command request + * @return Timeout for processing the request + */ + Timeout onKillTerminalCommandRequestTimeout(KillTerminalCommandRequest killTerminalCommandRequest); +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/session/event/consumer/exception/EventConsumeException.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/session/event/consumer/exception/EventConsumeException.java new file mode 100644 index 000000000..7a575327e --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/session/event/consumer/exception/EventConsumeException.java @@ -0,0 +1,89 @@ +package com.alibaba.acp.sdk.session.event.consumer.exception; + +import com.alibaba.acp.sdk.protocol.jsonrpc.Error; + +import org.apache.commons.lang3.exception.ContextedRuntimeException; +import org.apache.commons.lang3.exception.ExceptionContext; + +/** + * Exception thrown when an error occurs during event consumption. + * + * This exception is used to indicate problems that occur when processing events + * from an ACP agent, such as invalid request parameters or processing errors. + * It includes an error object that can be sent back to the agent. + * + * @author SkyFire + * @version 0.0.1 + */ +public class EventConsumeException extends ContextedRuntimeException { + /** + * Constructs a new EventConsumeException with no message or cause. + */ + public EventConsumeException() { + } + + /** + * Constructs a new EventConsumeException with the specified message. + * + * @param message The error message + */ + public EventConsumeException(String message) { + super(message); + } + + /** + * Constructs a new EventConsumeException with the specified message and cause. + * + * @param message The error message + * @param cause The underlying cause of the exception + */ + public EventConsumeException(String message, Throwable cause) { + super(message, cause); + } + + /** + * Constructs a new EventConsumeException with the specified message, cause, and context. + * + * @param message The error message + * @param cause The underlying cause of the exception + * @param context The exception context + */ + public EventConsumeException(String message, Throwable cause, ExceptionContext context) { + super(message, cause, context); + } + + /** + * Constructs a new EventConsumeException with the specified cause. + * + * @param cause The underlying cause of the exception + */ + public EventConsumeException(Throwable cause) { + super(cause); + } + + private final Error error = new Error(); + + /** + * Sets the error details for this exception. + * + * @param code The error code + * @param message The error message + * @param detail Additional error details + * @return This exception instance for method chaining + */ + public EventConsumeException setError(int code, String message, Object detail) { + error.setCode(code); + error.setMessage(message); + error.setData(detail); + return this; + } + + /** + * Gets the error object associated with this exception. + * + * @return The error object that can be sent back to the agent + */ + public Error getError() { + return error; + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/session/exception/SessionLoadException.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/session/exception/SessionLoadException.java new file mode 100644 index 000000000..e44844fd0 --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/session/exception/SessionLoadException.java @@ -0,0 +1,65 @@ +package com.alibaba.acp.sdk.session.exception; + +import org.apache.commons.lang3.exception.ContextedException; +import org.apache.commons.lang3.exception.ExceptionContext; + +/** + * Exception thrown when an error occurs during session creation. + * This exception is used to indicate problems that occur when attempting to create a new session + * with an ACP agent, such as communication errors or invalid session parameters. + * + * @author SkyFire + * @version 0.0.1 + */ +public class SessionLoadException extends ContextedException { + /** + * Constructs a new SessionLoadException with no message or cause. + */ + public SessionLoadException() { + } + + /** + * Constructs a new SessionLoadException with the specified message. + * + * @param message The error message + */ + public SessionLoadException(String message) { + super(message); + } + + /** + * Constructs a new SessionLoadException with the specified message and cause. + * + * @param message The error message + * @param cause The underlying cause of the exception + */ + public SessionLoadException(String message, Throwable cause) { + super(message, cause); + } + + /** + * Constructs a new SessionLoadException with the specified message, cause, and context. + * + * @param message The error message + * @param cause The underlying cause of the exception + * @param context The exception context + */ + public SessionLoadException(String message, Throwable cause, ExceptionContext context) { + super(message, cause, context); + } + + /** + * Constructs a new SessionLoadException with the specified cause. + * + * @param cause The underlying cause of the exception + */ + public SessionLoadException(Throwable cause) { + super(cause); + } + + @Override + public SessionLoadException addContextValue(String label, Object value) { + super.addContextValue(label, value); + return this; + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/session/exception/SessionNewException.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/session/exception/SessionNewException.java new file mode 100644 index 000000000..145c4dbda --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/session/exception/SessionNewException.java @@ -0,0 +1,73 @@ +package com.alibaba.acp.sdk.session.exception; + +import org.apache.commons.lang3.exception.ContextedException; +import org.apache.commons.lang3.exception.ExceptionContext; + +/** + * Exception thrown when an error occurs during session creation. + * + * This exception is used to indicate problems that occur when attempting to create a new session + * with an ACP agent, such as communication errors or invalid session parameters. + * + * @author SkyFire + * @version 0.0.1 + */ +public class SessionNewException extends ContextedException { + /** + * Constructs a new SessionNewException with no message or cause. + */ + public SessionNewException() { + } + + /** + * Constructs a new SessionNewException with the specified message. + * + * @param message The error message + */ + public SessionNewException(String message) { + super(message); + } + + /** + * Constructs a new SessionNewException with the specified message and cause. + * + * @param message The error message + * @param cause The underlying cause of the exception + */ + public SessionNewException(String message, Throwable cause) { + super(message, cause); + } + + /** + * Constructs a new SessionNewException with the specified message, cause, and context. + * + * @param message The error message + * @param cause The underlying cause of the exception + * @param context The exception context + */ + public SessionNewException(String message, Throwable cause, ExceptionContext context) { + super(message, cause, context); + } + + /** + * Constructs a new SessionNewException with the specified cause. + * + * @param cause The underlying cause of the exception + */ + public SessionNewException(Throwable cause) { + super(cause); + } + + @Override + /** + * Adds a context value to the exception for debugging purposes. + * + * @param label The label for the context value + * @param value The context value + * @return This exception instance for method chaining + */ + public SessionNewException addContextValue(String label, Object value) { + super.addContextValue(label, value); + return this; + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/transport/Transport.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/transport/Transport.java new file mode 100644 index 000000000..1258447f0 --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/transport/Transport.java @@ -0,0 +1,74 @@ +package com.alibaba.acp.sdk.transport; + +import java.io.IOException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; +import java.util.function.Function; + +/** + * ACP (Agent Client Protocol) Transport Interface + * + * Defines the transport layer contract for communication with AI agents, including message sending, receiving, and connection management functions. + * Classes implementing this interface should provide a reliable message transmission mechanism. + * + * @author SkyFire + * @version 0.0.1 + */ +public interface Transport { + /** + * Checks if the transport is currently reading. + * + * @return true if reading, false otherwise + */ + boolean isReading(); + + /** + * Starts the transport. + * + * @throws IOException if starting fails + */ + void start() throws IOException; + + /** + * Closes the transport and releases resources. + * + * @throws IOException if closing fails + */ + void close() throws IOException; + + /** + * Checks if the transport is available for communication. + * + * @return true if available, false otherwise + */ + boolean isAvailable(); + + /** + * Sends a message and waits for a single-line response. + * + * @param message The message to send + * @return The response message + * @throws IOException if an I/O error occurs + * @throws ExecutionException if an execution error occurs + * @throws InterruptedException if the operation is interrupted + * @throws TimeoutException if the operation times out + */ + String inputWaitForOneLine(String message) throws IOException, ExecutionException, InterruptedException, TimeoutException; + + /** + * Sends a message and waits for a multi-line response. + * + * @param message The message to send + * @param callBackFunction A function to process each line of the response + * @throws IOException if an I/O error occurs + */ + void inputWaitForMultiLine(String message, Function callBackFunction) throws IOException; + + /** + * Sends a message without waiting for a response. + * + * @param message The message to send + * @throws IOException if an I/O error occurs + */ + void inputNoWaitResponse(String message) throws IOException; +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/transport/process/ProcessTransport.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/transport/process/ProcessTransport.java new file mode 100644 index 000000000..85493e5e1 --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/transport/process/ProcessTransport.java @@ -0,0 +1,228 @@ +package com.alibaba.acp.sdk.transport.process; + +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.File; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +import java.lang.ProcessBuilder.Redirect; +import java.util.Optional; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; +import java.util.function.Function; + +import org.apache.commons.lang3.Validate; +import org.apache.commons.lang3.exception.ContextedRuntimeException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.alibaba.acp.sdk.transport.Transport; +import com.alibaba.acp.sdk.utils.MyConcurrentUtils; +import com.alibaba.acp.sdk.utils.Timeout; + +/** + * Implementation of the Transport interface that communicates with the ACP agent via a process. + * This class manages a subprocess that communicates with an ACP-compatible agent, + * handling message exchange, process lifecycle, and error handling. + * + * @author SkyFire + * @version 0.0.1 + */ +public class ProcessTransport implements Transport { + private static final Logger log = LoggerFactory.getLogger(ProcessTransport.class); + + private final String cwd; + private final String[] commandArgs; + + protected Timeout turnTimeout; + protected Timeout messageTimeout; + + protected volatile Process process; + protected BufferedWriter processInput; + protected BufferedReader processOutput; + protected BufferedReader processError; + protected Future processErrorFuture; + protected final Consumer errorHandler; + + private final AtomicBoolean reading = new AtomicBoolean(false); + + /** + * Constructs a new ProcessTransport with the specified options. + * + * @param transportOptions The transport options to configure the process transport + */ + public ProcessTransport(ProcessTransportOptions transportOptions) { + Validate.notNull(transportOptions, "transportOptions can not be null"); + + Validate.notEmpty(transportOptions.getCommandArgs(), "commandArgs of transportOptions can't be empty"); + this.commandArgs = transportOptions.getCommandArgs(); + + this.cwd = Optional.ofNullable(transportOptions.getCwd()).orElse("./"); + this.turnTimeout = Optional.ofNullable(transportOptions.getTurnTimeout()).orElse(Timeout.TIMEOUT_30_MINUTES); + this.messageTimeout = Optional.ofNullable(transportOptions.getMessageTimeout()).orElse(Timeout.TIMEOUT_180_SECONDS); + this.errorHandler = Optional.ofNullable(transportOptions.getErrorHandler()).orElse((line) -> log.error("process error: {}", line)); + } + + /** + * {@inheritDoc} + */ + @Override + public boolean isReading() { + return reading.get(); + } + + /** + * {@inheritDoc} + */ + @Override + public void start() throws IOException { + if (process != null) { + return; + } + ProcessBuilder processBuilder = new ProcessBuilder(commandArgs) + .redirectOutput(Redirect.PIPE) + .redirectInput(Redirect.PIPE) + .redirectError(Redirect.PIPE) + .redirectErrorStream(false) + .directory(new File(cwd)); + + process = processBuilder.start(); + processInput = new BufferedWriter(new OutputStreamWriter(process.getOutputStream())); + processOutput = new BufferedReader(new InputStreamReader(process.getInputStream())); + processError = new BufferedReader(new InputStreamReader(process.getErrorStream())); + startErrorReading(); + } + + /** + * {@inheritDoc} + */ + @Override + public void close() throws IOException { + if (process != null) { + process.getErrorStream().close(); + process.getOutputStream().close(); + process.getInputStream().close(); + process.destroy(); + } + if (processInput != null) { + processInput.close(); + } + if (processOutput != null) { + processOutput.close(); + } + if (processError != null) { + processError.close(); + } + } + + /** + * {@inheritDoc} + */ + @Override + public boolean isAvailable() { + return process != null && process.isAlive(); + } + + /** + * {@inheritDoc} + */ + @Override + public String inputWaitForOneLine(String message) throws IOException, ExecutionException, InterruptedException, TimeoutException { + return inputWaitForOneLine(message, turnTimeout); + } + + private String inputWaitForOneLine(String message, Timeout timeOut) + throws IOException, TimeoutException, InterruptedException, ExecutionException { + inputNoWaitResponse(message); + try { + reading.set(true); + String line = MyConcurrentUtils.runAndWait(() -> { + try { + return processOutput.readLine(); + } catch (IOException e) { + throw new ContextedRuntimeException("read line error", e) + .addContextValue("message", message); + } + }, timeOut); + log.trace("inputWaitForOneLine result: {}", line); + return line; + } finally { + reading.set(false); + } + } + + /** + * {@inheritDoc} + */ + @Override + public void inputWaitForMultiLine(String message, Function callBackFunction) throws IOException { + inputWaitForMultiLine(message, callBackFunction, turnTimeout); + } + + private void inputWaitForMultiLine(String message, Function callBackFunction, Timeout timeOut) throws IOException { + log.trace("input message for multiLine: {}", message); + inputNoWaitResponse(message); + MyConcurrentUtils.runAndWait(() -> iterateOutput(callBackFunction), timeOut); + } + + /** + * {@inheritDoc} + */ + @Override + public void inputNoWaitResponse(String message) throws IOException { + log.trace("input message to process: {}", message); + processInput.write(message); + processInput.newLine(); + processInput.flush(); + } + + private void startErrorReading() { + processErrorFuture = MyConcurrentUtils.asyncRun(() -> { + try { + for (; ; ) { + final String line = processError.readLine(); + if (line == null) { + break; + } + if (errorHandler != null) { + try { + MyConcurrentUtils.runAndWait(() -> errorHandler.accept(line), messageTimeout); + } catch (Exception e) { + log.warn("error handler error", e); + } + } + } + } catch (IOException e) { + log.warn("Failed read error {}, caused by {}", e.getMessage(), e.getCause(), e); + } + }, (e, t) -> log.warn("read error {}", t.getMessage(), t)); + } + + private void iterateOutput(Function callBackFunction) { + try { + reading.set(true); + MyConcurrentUtils.runAndWait(() -> { + try { + for (; ; ) { + String line = processOutput.readLine(); + if (line == null) { + break; + } + log.trace("read a message from process {}", line); + if (callBackFunction.apply(line)) { + break; + } + } + } catch (IOException e) { + throw new RuntimeException("read process output error", e); + } + }, messageTimeout); + } finally { + reading.set(false); + } + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/transport/process/ProcessTransportOptions.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/transport/process/ProcessTransportOptions.java new file mode 100644 index 000000000..ca95af414 --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/transport/process/ProcessTransportOptions.java @@ -0,0 +1,122 @@ +package com.alibaba.acp.sdk.transport.process; + +import java.util.function.Consumer; + +import com.alibaba.acp.sdk.utils.Timeout; + +/** + * Options for configuring ProcessTransport + * + * This class provides configuration options for ProcessTransport, including working directory, + * command arguments, error handling, and timeouts for different operations. + * + * @author SkyFire + * @version 0.0.1 + */ +public class ProcessTransportOptions { + private String cwd; + private String[] commandArgs; + private Consumer errorHandler; + private Timeout turnTimeout; + private Timeout messageTimeout; + + /** + * Gets the current working directory for the process + * + * @return The current working directory + */ + public String getCwd() { + return cwd; + } + + /** + * Sets the current working directory for the process + * + * @param cwd The current working directory + * @return Current instance for method chaining + */ + public ProcessTransportOptions setCwd(String cwd) { + this.cwd = cwd; + return this; + } + + /** + * Gets the command arguments for the process + * + * @return Array of command arguments + */ + public String[] getCommandArgs() { + return commandArgs; + } + + /** + * Sets the command arguments for the process + * + * @param commandArgs Array of command arguments + * @return Current instance for method chaining + */ + public ProcessTransportOptions setCommandArgs(String[] commandArgs) { + this.commandArgs = commandArgs; + return this; + } + + /** + * Gets the error handler for processing error messages + * + * @return Consumer for handling error messages + */ + public Consumer getErrorHandler() { + return errorHandler; + } + + /** + * Sets the error handler for processing error messages + * + * @param errorHandler Consumer for handling error messages + * @return Current instance for method chaining + */ + public ProcessTransportOptions setErrorHandler(Consumer errorHandler) { + this.errorHandler = errorHandler; + return this; + } + + /** + * Gets the timeout for a turn (conversation round) + * + * @return Timeout for a turn + */ + public Timeout getTurnTimeout() { + return turnTimeout; + } + + /** + * Sets the timeout for a turn (conversation round) + * + * @param turnTimeout Timeout for a turn + * @return Current instance for method chaining + */ + public ProcessTransportOptions setTurnTimeout(Timeout turnTimeout) { + this.turnTimeout = turnTimeout; + return this; + } + + /** + * Gets the timeout for individual messages + * + * @return Timeout for individual messages + */ + public Timeout getMessageTimeout() { + return messageTimeout; + } + + /** + * Sets the timeout for individual messages + * + * @param messageTimeout Timeout for individual messages + * @return Current instance for method chaining + */ + public ProcessTransportOptions setMessageTimeout(Timeout messageTimeout) { + this.messageTimeout = messageTimeout; + return this; + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/utils/AgentInitializeException.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/utils/AgentInitializeException.java new file mode 100644 index 000000000..e91febad1 --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/utils/AgentInitializeException.java @@ -0,0 +1,73 @@ +package com.alibaba.acp.sdk.utils; + +import org.apache.commons.lang3.exception.ContextedException; +import org.apache.commons.lang3.exception.ExceptionContext; + +/** + * Exception thrown when an error occurs during agent initialization. + * + * This exception is used to indicate problems that occur when attempting to initialize + * communication with an ACP agent, such as connection errors or protocol mismatches. + * + * @author SkyFire + * @version 0.0.1 + */ +public class AgentInitializeException extends ContextedException { + /** + * Constructs a new AgentInitializeException with no message or cause. + */ + public AgentInitializeException() { + } + + /** + * Constructs a new AgentInitializeException with the specified message. + * + * @param message The error message + */ + public AgentInitializeException(String message) { + super(message); + } + + /** + * Constructs a new AgentInitializeException with the specified message and cause. + * + * @param message The error message + * @param cause The underlying cause of the exception + */ + public AgentInitializeException(String message, Throwable cause) { + super(message, cause); + } + + /** + * Constructs a new AgentInitializeException with the specified message, cause, and context. + * + * @param message The error message + * @param cause The underlying cause of the exception + * @param context The exception context + */ + public AgentInitializeException(String message, Throwable cause, ExceptionContext context) { + super(message, cause, context); + } + + /** + * Constructs a new AgentInitializeException with the specified cause. + * + * @param cause The underlying cause of the exception + */ + public AgentInitializeException(Throwable cause) { + super(cause); + } + + @Override + /** + * Adds a context value to the exception for debugging purposes. + * + * @param label The label for the context value + * @param value The context value + * @return This exception instance for method chaining + */ + public AgentInitializeException addContextValue(String label, Object value) { + super.addContextValue(label, value); + return this; + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/utils/MyConcurrentUtils.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/utils/MyConcurrentUtils.java new file mode 100644 index 000000000..c25cc639e --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/utils/MyConcurrentUtils.java @@ -0,0 +1,83 @@ +package com.alibaba.acp.sdk.utils; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.TimeoutException; +import java.util.function.BiConsumer; +import java.util.function.Supplier; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Utility class for concurrent operations. + * + * @author skyfire + * @version $Id: 0.0.1 + */ +public class MyConcurrentUtils { + private static final Logger log = LoggerFactory.getLogger(MyConcurrentUtils.class); + + /** + * Runs a task and waits for it to complete with a timeout. + * + * @param runnable The task to run + * @param timeOut The timeout for the operation + */ + public static void runAndWait(Runnable runnable, Timeout timeOut) { + CompletableFuture future = CompletableFuture.runAsync(runnable, ThreadPoolConfig.getExecutor()); + try { + future.get(timeOut.getValue(), timeOut.getUnit()); + } catch (InterruptedException e) { + log.warn("task interrupted", e); + future.cancel(true); + } catch (TimeoutException e) { + log.warn("Operation timed out", e); + future.cancel(true); + } catch (Exception e) { + future.cancel(true); + log.warn("Operation error", e); + } + } + + /** + * Runs a task that returns a value and waits for it to complete with a timeout. + * + * @param supplier The task to run + * @param timeOut The timeout for the operation + * @param The type of the result + * @return The result of the task + * @throws ExecutionException if an execution error occurs + * @throws InterruptedException if the operation is interrupted + * @throws TimeoutException if the operation times out + */ + public static T runAndWait(Supplier supplier, Timeout timeOut) throws ExecutionException, InterruptedException, TimeoutException { + CompletableFuture future = CompletableFuture.supplyAsync(supplier, ThreadPoolConfig.getExecutor()); + try { + return future.get(timeOut.getValue(), timeOut.getUnit()); + } catch (TimeoutException | InterruptedException | ExecutionException e) { + future.cancel(true); + throw e; + } + } + + /** + * Runs a task asynchronously and returns a future for the result. + * + * @param runnable The task to run + * @param errorCallback The callback to invoke if an error occurs + * @return A future for the result + */ + public static Future asyncRun(Runnable runnable, BiConsumer errorCallback) { + CompletableFuture future = CompletableFuture.runAsync(() -> { + try { + runnable.run(); + } catch (Exception e) { + log.warn("async task error", e); + } + }, ThreadPoolConfig.getExecutor()); + future.whenComplete(errorCallback); + return future; + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/utils/ThreadPoolConfig.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/utils/ThreadPoolConfig.java new file mode 100644 index 000000000..f9d75b8f5 --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/utils/ThreadPoolConfig.java @@ -0,0 +1,77 @@ +package com.alibaba.acp.sdk.utils; + +import java.util.Optional; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Supplier; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Configuration for the thread pool used by the SDK. + * + * @author SkyFire + * @version 0.0.1 + */ +public class ThreadPoolConfig { + private static final Logger logger = LoggerFactory.getLogger(ThreadPoolConfig.class); + + private static final ThreadPoolExecutor defaultExecutor = new ThreadPoolExecutor( + 30, 100, 60L, TimeUnit.SECONDS, + new LinkedBlockingQueue<>(300), + new ThreadFactory() { + private final AtomicInteger threadNumber = new AtomicInteger(1); + + @Override + public Thread newThread(Runnable r) { + Thread t = new Thread(r, "acp-client-pool-" + threadNumber.getAndIncrement()); + t.setDaemon(false); + return t; + } + }, + new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略 + ); + + private static Supplier executorSupplier; + + /** + * Sets the supplier for the executor. + * + * @param executorSupplier The supplier for the executor + */ + public static void setExecutorSupplier(Supplier executorSupplier) { + ThreadPoolConfig.executorSupplier = executorSupplier; + } + + /** + * Gets the default executor. + * + * @return The default executor + */ + public static ThreadPoolExecutor getDefaultExecutor() { + return defaultExecutor; + } + + static ExecutorService getExecutor() { + return Optional.ofNullable(executorSupplier).map(s -> { + try { + return s.get(); + } catch (Exception e) { + logger.warn("Failed to getExecutor, will use defaultExecutor", e); + return defaultExecutor; + } + }).orElse(defaultExecutor); + } + + public static void shutdown() { + defaultExecutor.shutdownNow(); + if (getExecutor() != defaultExecutor) { + getExecutor().shutdownNow(); + } + } +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/utils/Timeout.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/utils/Timeout.java new file mode 100644 index 000000000..e6ee5bb15 --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/utils/Timeout.java @@ -0,0 +1,72 @@ +package com.alibaba.acp.sdk.utils; + +import java.util.concurrent.TimeUnit; + +import org.apache.commons.lang3.Validate; + +/** + * Represents a timeout value with a time unit. + * + * @author SkyFire + * @version 0.0.1 + */ +public class Timeout { + /** + * The timeout value. + */ + private final Long value; + /** + * The time unit. + */ + private final TimeUnit unit; + + /** + * Creates a new Timeout instance. + * + * @param value The timeout value + * @param unit The time unit + */ + public Timeout(Long value, TimeUnit unit) { + Validate.notNull(value, "value can not be null"); + Validate.notNull(unit, "unit can not be null"); + this.value = value; + this.unit = unit; + } + + /** + * Gets the timeout value. + * + * @return The timeout value + */ + public Long getValue() { + return value; + } + + /** + * Gets the time unit. + * + * @return The time unit + */ + public TimeUnit getUnit() { + return unit; + } + + /** A timeout of 3 seconds. + */ + public static final Timeout TIMEOUT_3_SECONDS = new Timeout(3L, TimeUnit.SECONDS); + + /** + * A timeout of 60 seconds. + */ + public static final Timeout TIMEOUT_60_SECONDS = new Timeout(60L, TimeUnit.SECONDS); + + /** + * A timeout of 180 seconds. + */ + public static final Timeout TIMEOUT_180_SECONDS = new Timeout(180L, TimeUnit.SECONDS); + + /** + * A timeout of 30 minutes. + */ + public static final Timeout TIMEOUT_30_MINUTES = new Timeout(60L, TimeUnit.MINUTES); +} diff --git a/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/utils/TransportUtils.java b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/utils/TransportUtils.java new file mode 100644 index 000000000..49b147f14 --- /dev/null +++ b/packages/sdk-java/client/src/main/java/com/alibaba/acp/sdk/utils/TransportUtils.java @@ -0,0 +1,38 @@ +package com.alibaba.acp.sdk.utils; + +import java.io.IOException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; + +import com.alibaba.acp.sdk.protocol.jsonrpc.Message; +import com.alibaba.acp.sdk.transport.Transport; +import com.alibaba.fastjson2.JSON; + +/** + * Transport Utilities Class + * + * Provides common utility methods related to the transport layer, such as sending messages and waiting for single-line responses. + * + * @author SkyFire + * @version 0.0.1 + */ +public class TransportUtils { + /** + * Sends a message and waits for a single-line response through the transport layer + * + * Sends the message to the transport layer, waits and receives a single-line response, then parses it into an object of the specified type. + * + * @param transport Transport instance for sending and receiving messages + * @param message Message object to send + * @param responseClass Target type for the response message + * @param Type of the response object + * @return Parsed response object + * @throws IOException Thrown when IO operations fail + * @throws ExecutionException Thrown when an error occurs during execution + * @throws InterruptedException Thrown when the operation is interrupted + * @throws TimeoutException Thrown when the operation times out + */ + public static C inputWaitForOneLine(Transport transport, Message message, Class responseClass) throws IOException, ExecutionException, InterruptedException, TimeoutException { + return JSON.parseObject(transport.inputWaitForOneLine(message.toString()), responseClass); + } +} diff --git a/packages/sdk-java/client/src/test/java/com/alibaba/acp/sdk/protocol/client/session/PermissionOptionKindTest.java b/packages/sdk-java/client/src/test/java/com/alibaba/acp/sdk/protocol/client/session/PermissionOptionKindTest.java new file mode 100644 index 000000000..d7c51d16a --- /dev/null +++ b/packages/sdk-java/client/src/test/java/com/alibaba/acp/sdk/protocol/client/session/PermissionOptionKindTest.java @@ -0,0 +1,31 @@ +package com.alibaba.acp.sdk.protocol.client.session; + +import com.alibaba.acp.sdk.protocol.domain.permission.PermissionOptionKind; +import com.alibaba.fastjson2.JSON; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class PermissionOptionKindTest { + + @Test + public void testJson() { + PermissionOptionKind kind = PermissionOptionKind.ALLOW_ONCE; + assertEquals("\"allow_once\"", JSON.toJSONString(kind)); + assertEquals(PermissionOptionKind.ALLOW_ONCE, JSON.parseObject("\"allow_once\"", PermissionOptionKind.class)); + + kind = PermissionOptionKind.ALLOW_ALWAYS; + assertEquals("\"allow_always\"", JSON.toJSONString(kind)); + assertEquals(PermissionOptionKind.ALLOW_ALWAYS, JSON.parseObject("\"allow_always\"", PermissionOptionKind.class)); + + kind = PermissionOptionKind.REJECT_ONCE; + assertEquals("\"reject_once\"", JSON.toJSONString(kind)); + assertEquals(PermissionOptionKind.REJECT_ONCE, JSON.parseObject("\"reject_once\"", PermissionOptionKind.class)); + + kind = PermissionOptionKind.REJECT_ALWAYS; + assertEquals("\"reject_always\"", JSON.toJSONString(kind)); + assertEquals(PermissionOptionKind.REJECT_ALWAYS, JSON.parseObject("\"reject_always\"", PermissionOptionKind.class)); + } + +} diff --git a/packages/sdk-java/client/src/test/java/com/alibaba/acp/sdk/protocol/client/session/PlanEntryPriorityTest.java b/packages/sdk-java/client/src/test/java/com/alibaba/acp/sdk/protocol/client/session/PlanEntryPriorityTest.java new file mode 100644 index 000000000..2a8ce41f5 --- /dev/null +++ b/packages/sdk-java/client/src/test/java/com/alibaba/acp/sdk/protocol/client/session/PlanEntryPriorityTest.java @@ -0,0 +1,27 @@ +package com.alibaba.acp.sdk.protocol.client.session; + +import com.alibaba.acp.sdk.protocol.domain.plan.PlanEntryPriority; +import com.alibaba.fastjson2.JSON; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class PlanEntryPriorityTest { + + @Test + public void testJson() { + PlanEntryPriority priority = PlanEntryPriority.HIGH; + assertEquals("\"high\"", JSON.toJSONString(priority)); + assertEquals(PlanEntryPriority.HIGH, JSON.parseObject("\"high\"", PlanEntryPriority.class)); + + priority = PlanEntryPriority.MEDIUM; + assertEquals("\"medium\"", JSON.toJSONString(priority)); + assertEquals(PlanEntryPriority.MEDIUM, JSON.parseObject("\"medium\"", PlanEntryPriority.class)); + + priority = PlanEntryPriority.LOW; + assertEquals("\"low\"", JSON.toJSONString(priority)); + assertEquals(PlanEntryPriority.LOW, JSON.parseObject("\"low\"", PlanEntryPriority.class)); + } + +} diff --git a/packages/sdk-java/client/src/test/java/com/alibaba/acp/sdk/protocol/client/session/PlanEntryStatusTest.java b/packages/sdk-java/client/src/test/java/com/alibaba/acp/sdk/protocol/client/session/PlanEntryStatusTest.java new file mode 100644 index 000000000..6880a5b04 --- /dev/null +++ b/packages/sdk-java/client/src/test/java/com/alibaba/acp/sdk/protocol/client/session/PlanEntryStatusTest.java @@ -0,0 +1,27 @@ +package com.alibaba.acp.sdk.protocol.client.session; + +import com.alibaba.acp.sdk.protocol.domain.plan.PlanEntryStatus; +import com.alibaba.fastjson2.JSON; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class PlanEntryStatusTest { + + @Test + public void testJson() { + PlanEntryStatus status = PlanEntryStatus.PENDING; + assertEquals("\"pending\"", JSON.toJSONString(status)); + assertEquals(PlanEntryStatus.PENDING, JSON.parseObject("\"pending\"", PlanEntryStatus.class)); + + status = PlanEntryStatus.IN_PROGRESS; + assertEquals("\"in_progress\"", JSON.toJSONString(status)); + assertEquals(PlanEntryStatus.IN_PROGRESS, JSON.parseObject("\"in_progress\"", PlanEntryStatus.class)); + + status = PlanEntryStatus.COMPLETED; + assertEquals("\"completed\"", JSON.toJSONString(status)); + assertEquals(PlanEntryStatus.COMPLETED, JSON.parseObject("\"completed\"", PlanEntryStatus.class)); + } + +} diff --git a/packages/sdk-java/client/src/test/java/com/alibaba/acp/sdk/protocol/client/session/StopReasonTest.java b/packages/sdk-java/client/src/test/java/com/alibaba/acp/sdk/protocol/client/session/StopReasonTest.java new file mode 100644 index 000000000..483d3ed10 --- /dev/null +++ b/packages/sdk-java/client/src/test/java/com/alibaba/acp/sdk/protocol/client/session/StopReasonTest.java @@ -0,0 +1,35 @@ +package com.alibaba.acp.sdk.protocol.client.session; + +import com.alibaba.acp.sdk.protocol.domain.session.StopReason; +import com.alibaba.fastjson2.JSON; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class StopReasonTest { + + @Test + public void testJson() { + StopReason reason = StopReason.END_TURN; + assertEquals("\"end_turn\"", JSON.toJSONString(reason)); + assertEquals(StopReason.END_TURN, JSON.parseObject("\"end_turn\"", StopReason.class)); + + reason = StopReason.MAX_TOKENS; + assertEquals("\"max_tokens\"", JSON.toJSONString(reason)); + assertEquals(StopReason.MAX_TOKENS, JSON.parseObject("\"max_tokens\"", StopReason.class)); + + reason = StopReason.MAX_TURN_REQUESTS; + assertEquals("\"max_turn_requests\"", JSON.toJSONString(reason)); + assertEquals(StopReason.MAX_TURN_REQUESTS, JSON.parseObject("\"max_turn_requests\"", StopReason.class)); + + reason = StopReason.REFUSAL; + assertEquals("\"refusal\"", JSON.toJSONString(reason)); + assertEquals(StopReason.REFUSAL, JSON.parseObject("\"refusal\"", StopReason.class)); + + reason = StopReason.CANCELLED; + assertEquals("\"cancelled\"", JSON.toJSONString(reason)); + assertEquals(StopReason.CANCELLED, JSON.parseObject("\"cancelled\"", StopReason.class)); + } + +} diff --git a/packages/sdk-java/client/src/test/java/com/alibaba/acp/sdk/protocol/client/session/ToolCallStatusTest.java b/packages/sdk-java/client/src/test/java/com/alibaba/acp/sdk/protocol/client/session/ToolCallStatusTest.java new file mode 100644 index 000000000..2d212b3f4 --- /dev/null +++ b/packages/sdk-java/client/src/test/java/com/alibaba/acp/sdk/protocol/client/session/ToolCallStatusTest.java @@ -0,0 +1,31 @@ +package com.alibaba.acp.sdk.protocol.client.session; + +import com.alibaba.acp.sdk.protocol.domain.tool.ToolCallStatus; +import com.alibaba.fastjson2.JSON; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class ToolCallStatusTest { + + @Test + public void testJson() { + ToolCallStatus status = ToolCallStatus.PENDING; + assertEquals("\"pending\"", JSON.toJSONString(status)); + assertEquals(ToolCallStatus.PENDING, JSON.parseObject("\"pending\"", ToolCallStatus.class)); + + status = ToolCallStatus.IN_PROGRESS; + assertEquals("\"in_progress\"", JSON.toJSONString(status)); + assertEquals(ToolCallStatus.IN_PROGRESS, JSON.parseObject("\"in_progress\"", ToolCallStatus.class)); + + status = ToolCallStatus.COMPLETED; + assertEquals("\"completed\"", JSON.toJSONString(status)); + assertEquals(ToolCallStatus.COMPLETED, JSON.parseObject("\"completed\"", ToolCallStatus.class)); + + status = ToolCallStatus.FAILED; + assertEquals("\"failed\"", JSON.toJSONString(status)); + assertEquals(ToolCallStatus.FAILED, JSON.parseObject("\"failed\"", ToolCallStatus.class)); + } + +} diff --git a/packages/sdk-java/client/src/test/java/com/alibaba/acp/sdk/protocol/client/session/ToolKindTest.java b/packages/sdk-java/client/src/test/java/com/alibaba/acp/sdk/protocol/client/session/ToolKindTest.java new file mode 100644 index 000000000..69cba5388 --- /dev/null +++ b/packages/sdk-java/client/src/test/java/com/alibaba/acp/sdk/protocol/client/session/ToolKindTest.java @@ -0,0 +1,55 @@ +package com.alibaba.acp.sdk.protocol.client.session; + +import com.alibaba.acp.sdk.protocol.domain.tool.ToolKind; +import com.alibaba.fastjson2.JSON; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class ToolKindTest { + + @Test + public void testJson() { + ToolKind kind = ToolKind.READ; + assertEquals("\"read\"", JSON.toJSONString(kind)); + assertEquals(ToolKind.READ, JSON.parseObject("\"read\"", ToolKind.class)); + + kind = ToolKind.EDIT; + assertEquals("\"edit\"", JSON.toJSONString(kind)); + assertEquals(ToolKind.EDIT, JSON.parseObject("\"edit\"", ToolKind.class)); + + kind = ToolKind.DELETE; + assertEquals("\"delete\"", JSON.toJSONString(kind)); + assertEquals(ToolKind.DELETE, JSON.parseObject("\"delete\"", ToolKind.class)); + + kind = ToolKind.MOVE; + assertEquals("\"move\"", JSON.toJSONString(kind)); + assertEquals(ToolKind.MOVE, JSON.parseObject("\"move\"", ToolKind.class)); + + kind = ToolKind.SEARCH; + assertEquals("\"search\"", JSON.toJSONString(kind)); + assertEquals(ToolKind.SEARCH, JSON.parseObject("\"search\"", ToolKind.class)); + + kind = ToolKind.EXECUTE; + assertEquals("\"execute\"", JSON.toJSONString(kind)); + assertEquals(ToolKind.EXECUTE, JSON.parseObject("\"execute\"", ToolKind.class)); + + kind = ToolKind.THINK; + assertEquals("\"think\"", JSON.toJSONString(kind)); + assertEquals(ToolKind.THINK, JSON.parseObject("\"think\"", ToolKind.class)); + + kind = ToolKind.FETCH; + assertEquals("\"fetch\"", JSON.toJSONString(kind)); + assertEquals(ToolKind.FETCH, JSON.parseObject("\"fetch\"", ToolKind.class)); + + kind = ToolKind.SWITCH_MODE; + assertEquals("\"switch_mode\"", JSON.toJSONString(kind)); + assertEquals(ToolKind.SWITCH_MODE, JSON.parseObject("\"switch_mode\"", ToolKind.class)); + + kind = ToolKind.OTHER; + assertEquals("\"other\"", JSON.toJSONString(kind)); + assertEquals(ToolKind.OTHER, JSON.parseObject("\"other\"", ToolKind.class)); + } + +} diff --git a/packages/sdk-java/client/src/test/java/com/alibaba/acp/sdk/session/SessionTest.java b/packages/sdk-java/client/src/test/java/com/alibaba/acp/sdk/session/SessionTest.java new file mode 100644 index 000000000..1ce1ad926 --- /dev/null +++ b/packages/sdk-java/client/src/test/java/com/alibaba/acp/sdk/session/SessionTest.java @@ -0,0 +1,128 @@ +package com.alibaba.acp.sdk.session; + +import java.io.IOException; +import java.util.Collections; +import java.util.Optional; + +import com.alibaba.acp.sdk.AcpClient; +import com.alibaba.acp.sdk.protocol.domain.client.ClientCapabilities; +import com.alibaba.acp.sdk.protocol.domain.client.ClientCapabilities.FileSystemCapability; +import com.alibaba.acp.sdk.protocol.domain.content.block.TextContent; +import com.alibaba.acp.sdk.protocol.domain.permission.PermissionOption; +import com.alibaba.acp.sdk.protocol.domain.permission.RequestPermissionOutcome; +import com.alibaba.acp.sdk.protocol.domain.session.update.AgentMessageChunkSessionUpdate; +import com.alibaba.acp.sdk.protocol.domain.session.update.AvailableCommandsUpdateSessionUpdate; +import com.alibaba.acp.sdk.protocol.domain.session.update.CurrentModeUpdateSessionUpdate; +import com.alibaba.acp.sdk.protocol.domain.session.update.PlanSessionUpdate; +import com.alibaba.acp.sdk.protocol.domain.session.update.ToolCallSessionUpdate; +import com.alibaba.acp.sdk.protocol.domain.session.update.ToolCallUpdateSessionUpdate; +import com.alibaba.acp.sdk.session.event.consumer.ContentEventSimpleConsumer; +import com.alibaba.acp.sdk.session.event.consumer.FileEventSimpleConsumer; +import com.alibaba.acp.sdk.utils.AgentInitializeException; +import com.alibaba.acp.sdk.protocol.agent.request.RequestPermissionRequest; +import com.alibaba.acp.sdk.protocol.agent.request.RequestPermissionRequest.RequestPermissionRequestParams; +import com.alibaba.acp.sdk.protocol.client.request.InitializeRequest.InitializeRequestParams; +import com.alibaba.acp.sdk.protocol.client.request.NewSessionRequest.NewSessionRequestParams; +import com.alibaba.acp.sdk.protocol.client.response.RequestPermissionResponse.RequestPermissionResponseResult; +import com.alibaba.acp.sdk.protocol.domain.permission.PermissionOutcomeKind; +import com.alibaba.acp.sdk.protocol.jsonrpc.MethodMessage; +import com.alibaba.acp.sdk.session.event.consumer.AgentEventConsumer; +import com.alibaba.acp.sdk.session.event.consumer.PermissionEventConsumer; +import com.alibaba.acp.sdk.session.event.consumer.exception.EventConsumeException; +import com.alibaba.acp.sdk.session.exception.SessionNewException; +import com.alibaba.acp.sdk.transport.Transport; +import com.alibaba.acp.sdk.transport.process.ProcessTransport; +import com.alibaba.acp.sdk.transport.process.ProcessTransportOptions; +import com.alibaba.acp.sdk.utils.Timeout; + +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import static com.alibaba.acp.sdk.protocol.domain.permission.PermissionOptionKind.ALLOW_ALWAYS; + +class SessionTest { + private static final Logger logger = LoggerFactory.getLogger(SessionTest.class); + @Test + public void testSession() throws AgentInitializeException, SessionNewException, IOException { + AcpClient acpClient = new AcpClient(new ProcessTransport(new ProcessTransportOptions().setCommandArgs(new String[] {"qwen", "--acp", "--experimental-skills", "-y"}))); + try { + acpClient.sendPrompt(Collections.singletonList(new TextContent("你是谁")), new AgentEventConsumer().setContentEventConsumer(new ContentEventSimpleConsumer(){ + @Override + public void onAgentMessageChunkSessionUpdate(AgentMessageChunkSessionUpdate sessionUpdate) { + logger.info(sessionUpdate.toString()); + } + + @Override + public void onAvailableCommandsUpdateSessionUpdate(AvailableCommandsUpdateSessionUpdate sessionUpdate) { + logger.info(sessionUpdate.toString()); + } + + @Override + public void onCurrentModeUpdateSessionUpdate(CurrentModeUpdateSessionUpdate sessionUpdate) { + logger.info(sessionUpdate.toString()); + } + + @Override + public void onPlanSessionUpdate(PlanSessionUpdate sessionUpdate) { + logger.info(sessionUpdate.toString()); + } + + @Override + public void onToolCallUpdateSessionUpdate(ToolCallUpdateSessionUpdate sessionUpdate) { + logger.info(sessionUpdate.toString()); + } + + @Override + public void onToolCallSessionUpdate(ToolCallSessionUpdate sessionUpdate) { + logger.info(sessionUpdate.toString()); + } + })); + } finally { + acpClient.close(); + } + } + + @Test + void test() throws SessionNewException, AgentInitializeException, IOException { + Transport transport = new ProcessTransport( + new ProcessTransportOptions().setCommandArgs(new String[] {"qwen", "--acp", "--experimental-skills", "-y"})); + AcpClient acpClient = new AcpClient(transport, new InitializeRequestParams().setClientCapabilities( + new ClientCapabilities() + .setTerminal(true) + .setFs(new FileSystemCapability().setReadTextFile(true).setWriteTextFile(true)))); + Session session = acpClient.newSession(new NewSessionRequestParams()); + session.sendPrompt(Collections.singletonList(new TextContent("你是谁")), new AgentEventConsumer()); + } + + @Test + void testPermission() throws AgentInitializeException, SessionNewException, IOException { + Transport transport = new ProcessTransport( + new ProcessTransportOptions().setCommandArgs(new String[] {"qwen", "--acp", "--experimental-skills"})); + AcpClient acpClient = new AcpClient(transport, new InitializeRequestParams().setClientCapabilities( + new ClientCapabilities() + .setTerminal(false) + .setFs(new FileSystemCapability(true, true)))); + Session session = acpClient.newSession(new NewSessionRequestParams()); + session.sendPrompt(Collections.singletonList(new TextContent("创建一个test.touch文件")) + , new AgentEventConsumer().setFileEventConsumer(new FileEventSimpleConsumer()).setPermissionEventConsumer(new PermissionEventConsumer() { + @Override + public RequestPermissionResponseResult onRequestPermissionRequest(RequestPermissionRequest request) throws EventConsumeException { + return new RequestPermissionResponseResult(new RequestPermissionOutcome(). + setOptionId(Optional.of(request) + .map(MethodMessage::getParams) + .map(RequestPermissionRequestParams::getOptions) + .flatMap(options -> options.stream() + .filter(option -> ALLOW_ALWAYS.equals(option.getKind())) + .findFirst()) + .map(PermissionOption::getOptionId).orElse(null)) + .setOutcome(PermissionOutcomeKind.SELECTED)); + } + + @Override + public Timeout onRequestPermissionRequestTimeout(RequestPermissionRequest request) { + return Timeout.TIMEOUT_60_SECONDS; + } + })); + } +} diff --git a/packages/sdk-java/client/src/test/java/com/alibaba/acp/sdk/test/EnumTest.java b/packages/sdk-java/client/src/test/java/com/alibaba/acp/sdk/test/EnumTest.java new file mode 100644 index 000000000..68e3bdfeb --- /dev/null +++ b/packages/sdk-java/client/src/test/java/com/alibaba/acp/sdk/test/EnumTest.java @@ -0,0 +1,22 @@ +package com.alibaba.acp.sdk.test; + +import com.alibaba.acp.sdk.protocol.domain.plan.PlanEntryStatus; +import com.alibaba.fastjson2.JSON; + +public class EnumTest { + public static void main(String[] args) { + // Test PlanEntryStatus enum + PlanEntryStatus status = PlanEntryStatus.PENDING; + String json = JSON.toJSONString(status); + System.out.println("JSON output: " + json); + + PlanEntryStatus parsed = JSON.parseObject("\"pending\"", PlanEntryStatus.class); + System.out.println("Parsed enum: " + parsed); + + if ("\"pending\"".equals(json) && PlanEntryStatus.PENDING == parsed) { + System.out.println("SUCCESS: PlanEntryStatus enum works correctly!"); + } else { + System.out.println("FAILURE: PlanEntryStatus enum not working properly"); + } + } +} diff --git a/packages/sdk-java/client/src/test/resources/schema/schema.json b/packages/sdk-java/client/src/test/resources/schema/schema.json new file mode 100644 index 000000000..eba179457 --- /dev/null +++ b/packages/sdk-java/client/src/test/resources/schema/schema.json @@ -0,0 +1,3105 @@ +{ + "$defs": { + "AgentCapabilities": { + "description": "Capabilities supported by the agent.\n\nAdvertised during initialization to inform the client about\navailable features and content types.\n\nSee protocol docs: [Agent Capabilities](https://agentclientprotocol.com/protocol/initialization#agent-capabilities)", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "loadSession": { + "default": false, + "description": "Whether the agent supports `session/load`.", + "type": "boolean" + }, + "mcpCapabilities": { + "allOf": [ + { + "$ref": "#/$defs/McpCapabilities" + } + ], + "default": { + "http": false, + "sse": false + }, + "description": "MCP capabilities supported by the agent." + }, + "promptCapabilities": { + "allOf": [ + { + "$ref": "#/$defs/PromptCapabilities" + } + ], + "default": { + "audio": false, + "embeddedContext": false, + "image": false + }, + "description": "Prompt capabilities supported by the agent." + }, + "sessionCapabilities": { + "allOf": [ + { + "$ref": "#/$defs/SessionCapabilities" + } + ], + "default": {} + } + }, + "type": "object" + }, + "AgentNotification": { + "properties": { + "method": { + "type": "string" + }, + "params": { + "anyOf": [ + { + "anyOf": [ + { + "allOf": [ + { + "$ref": "#/$defs/SessionNotification" + } + ], + "description": "Handles session update notifications from the agent.\n\nThis is a notification endpoint (no response expected) that receives\nreal-time updates about session progress, including message chunks,\ntool calls, and execution plans.\n\nNote: Clients SHOULD continue accepting tool call updates even after\nsending a `session/cancel` notification, as the agent may send final\nupdates before responding with the cancelled stop reason.\n\nSee protocol docs: [Agent Reports Output](https://agentclientprotocol.com/protocol/prompt-turn#3-agent-reports-output)", + "title": "SessionNotification" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ExtNotification" + } + ], + "description": "Handles extension notifications from the agent.\n\nAllows the Agent to send an arbitrary notification that is not part of the ACP spec.\nExtension notifications provide a way to send one-way messages for custom functionality\nwhile maintaining protocol compatibility.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "title": "ExtNotification" + } + ], + "description": "All possible notifications that an agent can send to a client.\n\nThis enum is used internally for routing RPC notifications. You typically won't need\nto use this directly - use the notification methods on the [`Client`] trait instead.\n\nNotifications do not expect a response." + }, + { + "type": "null" + } + ] + } + }, + "required": ["method"], + "type": "object", + "x-docs-ignore": true + }, + "AgentRequest": { + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "method": { + "type": "string" + }, + "params": { + "anyOf": [ + { + "anyOf": [ + { + "allOf": [ + { + "$ref": "#/$defs/WriteTextFileRequest" + } + ], + "description": "Writes content to a text file in the client's file system.\n\nOnly available if the client advertises the `fs.writeTextFile` capability.\nAllows the agent to create or modify files within the client's environment.\n\nSee protocol docs: [Client](https://agentclientprotocol.com/protocol/overview#client)", + "title": "WriteTextFileRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ReadTextFileRequest" + } + ], + "description": "Reads content from a text file in the client's file system.\n\nOnly available if the client advertises the `fs.readTextFile` capability.\nAllows the agent to access file contents within the client's environment.\n\nSee protocol docs: [Client](https://agentclientprotocol.com/protocol/overview#client)", + "title": "ReadTextFileRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/RequestPermissionRequest" + } + ], + "description": "Requests permission from the user for a tool call operation.\n\nCalled by the agent when it needs user authorization before executing\na potentially sensitive operation. The client should present the options\nto the user and return their decision.\n\nIf the client cancels the prompt turn via `session/cancel`, it MUST\nrespond to this request with `RequestPermissionOutcome::Cancelled`.\n\nSee protocol docs: [Requesting Permission](https://agentclientprotocol.com/protocol/tool-calls#requesting-permission)", + "title": "RequestPermissionRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/CreateTerminalRequest" + } + ], + "description": "Executes a command in a new terminal\n\nOnly available if the `terminal` Client capability is set to `true`.\n\nReturns a `TerminalId` that can be used with other terminal methods\nto get the current output, wait for exit, and kill the command.\n\nThe `TerminalId` can also be used to embed the terminal in a tool call\nby using the `ToolCallContent::Terminal` variant.\n\nThe Agent is responsible for releasing the terminal by using the `terminal/release`\nmethod.\n\nSee protocol docs: [Terminals](https://agentclientprotocol.com/protocol/terminals)", + "title": "CreateTerminalRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/TerminalOutputRequest" + } + ], + "description": "Gets the terminal output and exit status\n\nReturns the current content in the terminal without waiting for the command to exit.\nIf the command has already exited, the exit status is included.\n\nSee protocol docs: [Terminals](https://agentclientprotocol.com/protocol/terminals)", + "title": "TerminalOutputRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ReleaseTerminalRequest" + } + ], + "description": "Releases a terminal\n\nThe command is killed if it hasn't exited yet. Use `terminal/wait_for_exit`\nto wait for the command to exit before releasing the terminal.\n\nAfter release, the `TerminalId` can no longer be used with other `terminal/*` methods,\nbut tool calls that already contain it, continue to display its output.\n\nThe `terminal/kill` method can be used to terminate the command without releasing\nthe terminal, allowing the Agent to call `terminal/output` and other methods.\n\nSee protocol docs: [Terminals](https://agentclientprotocol.com/protocol/terminals)", + "title": "ReleaseTerminalRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/WaitForTerminalExitRequest" + } + ], + "description": "Waits for the terminal command to exit and return its exit status\n\nSee protocol docs: [Terminals](https://agentclientprotocol.com/protocol/terminals)", + "title": "WaitForTerminalExitRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/KillTerminalCommandRequest" + } + ], + "description": "Kills the terminal command without releasing the terminal\n\nWhile `terminal/release` will also kill the command, this method will keep\nthe `TerminalId` valid so it can be used with other methods.\n\nThis method can be helpful when implementing command timeouts which terminate\nthe command as soon as elapsed, and then get the final output so it can be sent\nto the model.\n\nNote: `terminal/release` when `TerminalId` is no longer needed.\n\nSee protocol docs: [Terminals](https://agentclientprotocol.com/protocol/terminals)", + "title": "KillTerminalCommandRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ExtRequest" + } + ], + "description": "Handles extension method requests from the agent.\n\nAllows the Agent to send an arbitrary request that is not part of the ACP spec.\nExtension methods provide a way to add custom functionality while maintaining\nprotocol compatibility.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "title": "ExtMethodRequest" + } + ], + "description": "All possible requests that an agent can send to a client.\n\nThis enum is used internally for routing RPC requests. You typically won't need\nto use this directly - instead, use the methods on the [`Client`] trait.\n\nThis enum encompasses all method calls from agent to client." + }, + { + "type": "null" + } + ] + } + }, + "required": ["id", "method"], + "type": "object", + "x-docs-ignore": true + }, + "AgentResponse": { + "anyOf": [ + { + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "result": { + "anyOf": [ + { + "allOf": [ + { + "$ref": "#/$defs/InitializeResponse" + } + ], + "title": "InitializeResponse" + }, + { + "allOf": [ + { + "$ref": "#/$defs/AuthenticateResponse" + } + ], + "title": "AuthenticateResponse" + }, + { + "allOf": [ + { + "$ref": "#/$defs/NewSessionResponse" + } + ], + "title": "NewSessionResponse" + }, + { + "allOf": [ + { + "$ref": "#/$defs/LoadSessionResponse" + } + ], + "title": "LoadSessionResponse" + }, + { + "allOf": [ + { + "$ref": "#/$defs/SetSessionModeResponse" + } + ], + "title": "SetSessionModeResponse" + }, + { + "allOf": [ + { + "$ref": "#/$defs/PromptResponse" + } + ], + "title": "PromptResponse" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ExtResponse" + } + ], + "title": "ExtMethodResponse" + } + ], + "description": "All possible responses that an agent can send to a client.\n\nThis enum is used internally for routing RPC responses. You typically won't need\nto use this directly - the responses are handled automatically by the connection.\n\nThese are responses to the corresponding `ClientRequest` variants." + } + }, + "required": ["id", "result"], + "title": "Result", + "type": "object" + }, + { + "properties": { + "error": { + "$ref": "#/$defs/Error" + }, + "id": { + "$ref": "#/$defs/RequestId" + } + }, + "required": ["id", "error"], + "title": "Error", + "type": "object" + } + ], + "x-docs-ignore": true + }, + "Annotations": { + "description": "Optional annotations for the client. The client can use annotations to inform how objects are used or displayed", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "audience": { + "items": { + "$ref": "#/$defs/Role" + }, + "type": ["array", "null"] + }, + "lastModified": { + "type": ["string", "null"] + }, + "priority": { + "format": "double", + "type": ["number", "null"] + } + }, + "type": "object" + }, + "AudioContent": { + "description": "Audio provided to or from an LLM.", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "annotations": { + "anyOf": [ + { + "$ref": "#/$defs/Annotations" + }, + { + "type": "null" + } + ] + }, + "data": { + "type": "string" + }, + "mimeType": { + "type": "string" + } + }, + "required": ["data", "mimeType"], + "type": "object" + }, + "AuthMethod": { + "description": "Describes an available authentication method.", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "description": { + "description": "Optional description providing more details about this authentication method.", + "type": ["string", "null"] + }, + "id": { + "description": "Unique identifier for this authentication method.", + "type": "string" + }, + "name": { + "description": "Human-readable name of the authentication method.", + "type": "string" + } + }, + "required": ["id", "name"], + "type": "object" + }, + "AuthenticateRequest": { + "description": "Request parameters for the authenticate method.\n\nSpecifies which authentication method to use.", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "methodId": { + "description": "The ID of the authentication method to use.\nMust be one of the methods advertised in the initialize response.", + "type": "string" + } + }, + "required": ["methodId"], + "type": "object", + "x-method": "authenticate", + "x-side": "agent" + }, + "AuthenticateResponse": { + "description": "Response to the `authenticate` method.", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + } + }, + "type": "object", + "x-method": "authenticate", + "x-side": "agent" + }, + "AvailableCommand": { + "description": "Information about a command.", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "description": { + "description": "Human-readable description of what the command does.", + "type": "string" + }, + "input": { + "anyOf": [ + { + "$ref": "#/$defs/AvailableCommandInput" + }, + { + "type": "null" + } + ], + "description": "Input for the command if required" + }, + "name": { + "description": "Command name (e.g., `create_plan`, `research_codebase`).", + "type": "string" + } + }, + "required": ["name", "description"], + "type": "object" + }, + "AvailableCommandInput": { + "anyOf": [ + { + "allOf": [ + { + "$ref": "#/$defs/UnstructuredCommandInput" + } + ], + "description": "All text that was typed after the command name is provided as input.", + "title": "unstructured" + } + ], + "description": "The input specification for a command." + }, + "AvailableCommandsUpdate": { + "description": "Available commands are ready or have changed", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "availableCommands": { + "description": "Commands the agent can execute", + "items": { + "$ref": "#/$defs/AvailableCommand" + }, + "type": "array" + } + }, + "required": ["availableCommands"], + "type": "object" + }, + "BlobResourceContents": { + "description": "Binary resource contents.", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "blob": { + "type": "string" + }, + "mimeType": { + "type": ["string", "null"] + }, + "uri": { + "type": "string" + } + }, + "required": ["blob", "uri"], + "type": "object" + }, + "CancelNotification": { + "description": "Notification to cancel ongoing operations for a session.\n\nSee protocol docs: [Cancellation](https://agentclientprotocol.com/protocol/prompt-turn#cancellation)", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "sessionId": { + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ], + "description": "The ID of the session to cancel operations for." + } + }, + "required": ["sessionId"], + "type": "object", + "x-method": "session/cancel", + "x-side": "agent" + }, + "ClientCapabilities": { + "description": "Capabilities supported by the client.\n\nAdvertised during initialization to inform the agent about\navailable features and methods.\n\nSee protocol docs: [Client Capabilities](https://agentclientprotocol.com/protocol/initialization#client-capabilities)", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "fs": { + "allOf": [ + { + "$ref": "#/$defs/FileSystemCapability" + } + ], + "default": { + "readTextFile": false, + "writeTextFile": false + }, + "description": "File system capabilities supported by the client.\nDetermines which file operations the agent can request." + }, + "terminal": { + "default": false, + "description": "Whether the Client support all `terminal/*` methods.", + "type": "boolean" + } + }, + "type": "object" + }, + "ClientNotification": { + "properties": { + "method": { + "type": "string" + }, + "params": { + "anyOf": [ + { + "anyOf": [ + { + "allOf": [ + { + "$ref": "#/$defs/CancelNotification" + } + ], + "description": "Cancels ongoing operations for a session.\n\nThis is a notification sent by the client to cancel an ongoing prompt turn.\n\nUpon receiving this notification, the Agent SHOULD:\n- Stop all language model requests as soon as possible\n- Abort all tool call invocations in progress\n- Send any pending `session/update` notifications\n- Respond to the original `session/prompt` request with `StopReason::Cancelled`\n\nSee protocol docs: [Cancellation](https://agentclientprotocol.com/protocol/prompt-turn#cancellation)", + "title": "CancelNotification" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ExtNotification" + } + ], + "description": "Handles extension notifications from the client.\n\nExtension notifications provide a way to send one-way messages for custom functionality\nwhile maintaining protocol compatibility.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "title": "ExtNotification" + } + ], + "description": "All possible notifications that a client can send to an agent.\n\nThis enum is used internally for routing RPC notifications. You typically won't need\nto use this directly - use the notification methods on the [`Agent`] trait instead.\n\nNotifications do not expect a response." + }, + { + "type": "null" + } + ] + } + }, + "required": ["method"], + "type": "object", + "x-docs-ignore": true + }, + "ClientRequest": { + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "method": { + "type": "string" + }, + "params": { + "anyOf": [ + { + "anyOf": [ + { + "allOf": [ + { + "$ref": "#/$defs/InitializeRequest" + } + ], + "description": "Establishes the connection with a client and negotiates protocol capabilities.\n\nThis method is called once at the beginning of the connection to:\n- Negotiate the protocol version to use\n- Exchange capability information between client and agent\n- Determine available authentication methods\n\nThe agent should respond with its supported protocol version and capabilities.\n\nSee protocol docs: [Initialization](https://agentclientprotocol.com/protocol/initialization)", + "title": "InitializeRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/AuthenticateRequest" + } + ], + "description": "Authenticates the client using the specified authentication method.\n\nCalled when the agent requires authentication before allowing session creation.\nThe client provides the authentication method ID that was advertised during initialization.\n\nAfter successful authentication, the client can proceed to create sessions with\n`new_session` without receiving an `auth_required` error.\n\nSee protocol docs: [Initialization](https://agentclientprotocol.com/protocol/initialization)", + "title": "AuthenticateRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/NewSessionRequest" + } + ], + "description": "Creates a new conversation session with the agent.\n\nSessions represent independent conversation contexts with their own history and state.\n\nThe agent should:\n- Create a new session context\n- Connect to any specified MCP servers\n- Return a unique session ID for future requests\n\nMay return an `auth_required` error if the agent requires authentication.\n\nSee protocol docs: [Session Setup](https://agentclientprotocol.com/protocol/session-setup)", + "title": "NewSessionRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/LoadSessionRequest" + } + ], + "description": "Loads an existing session to resume a previous conversation.\n\nThis method is only available if the agent advertises the `loadSession` capability.\n\nThe agent should:\n- Restore the session context and conversation history\n- Connect to the specified MCP servers\n- Stream the entire conversation history back to the client via notifications\n\nSee protocol docs: [Loading Sessions](https://agentclientprotocol.com/protocol/session-setup#loading-sessions)", + "title": "LoadSessionRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/SetSessionModeRequest" + } + ], + "description": "Sets the current mode for a session.\n\nAllows switching between different agent modes (e.g., \"ask\", \"architect\", \"code\")\nthat affect system prompts, tool availability, and permission behaviors.\n\nThe mode must be one of the modes advertised in `availableModes` during session\ncreation or loading. Agents may also change modes autonomously and notify the\nclient via `current_mode_update` notifications.\n\nThis method can be called at any time during a session, whether the Agent is\nidle or actively generating a response.\n\nSee protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes)", + "title": "SetSessionModeRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/PromptRequest" + } + ], + "description": "Processes a user prompt within a session.\n\nThis method handles the whole lifecycle of a prompt:\n- Receives user messages with optional context (files, images, etc.)\n- Processes the prompt using language models\n- Reports language model content and tool calls to the Clients\n- Requests permission to run tools\n- Executes any requested tool calls\n- Returns when the turn is complete with a stop reason\n\nSee protocol docs: [Prompt Turn](https://agentclientprotocol.com/protocol/prompt-turn)", + "title": "PromptRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ExtRequest" + } + ], + "description": "Handles extension method requests from the client.\n\nExtension methods provide a way to add custom functionality while maintaining\nprotocol compatibility.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "title": "ExtMethodRequest" + } + ], + "description": "All possible requests that a client can send to an agent.\n\nThis enum is used internally for routing RPC requests. You typically won't need\nto use this directly - instead, use the methods on the [`Agent`] trait.\n\nThis enum encompasses all method calls from client to agent." + }, + { + "type": "null" + } + ] + } + }, + "required": ["id", "method"], + "type": "object", + "x-docs-ignore": true + }, + "ClientResponse": { + "anyOf": [ + { + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "result": { + "anyOf": [ + { + "allOf": [ + { + "$ref": "#/$defs/WriteTextFileResponse" + } + ], + "title": "WriteTextFileResponse" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ReadTextFileResponse" + } + ], + "title": "ReadTextFileResponse" + }, + { + "allOf": [ + { + "$ref": "#/$defs/RequestPermissionResponse" + } + ], + "title": "RequestPermissionResponse" + }, + { + "allOf": [ + { + "$ref": "#/$defs/CreateTerminalResponse" + } + ], + "title": "CreateTerminalResponse" + }, + { + "allOf": [ + { + "$ref": "#/$defs/TerminalOutputResponse" + } + ], + "title": "TerminalOutputResponse" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ReleaseTerminalResponse" + } + ], + "title": "ReleaseTerminalResponse" + }, + { + "allOf": [ + { + "$ref": "#/$defs/WaitForTerminalExitResponse" + } + ], + "title": "WaitForTerminalExitResponse" + }, + { + "allOf": [ + { + "$ref": "#/$defs/KillTerminalCommandResponse" + } + ], + "title": "KillTerminalResponse" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ExtResponse" + } + ], + "title": "ExtMethodResponse" + } + ], + "description": "All possible responses that a client can send to an agent.\n\nThis enum is used internally for routing RPC responses. You typically won't need\nto use this directly - the responses are handled automatically by the connection.\n\nThese are responses to the corresponding `AgentRequest` variants." + } + }, + "required": ["id", "result"], + "title": "Result", + "type": "object" + }, + { + "properties": { + "error": { + "$ref": "#/$defs/Error" + }, + "id": { + "$ref": "#/$defs/RequestId" + } + }, + "required": ["id", "error"], + "title": "Error", + "type": "object" + } + ], + "x-docs-ignore": true + }, + "Content": { + "description": "Standard content block (text, images, resources).", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "content": { + "allOf": [ + { + "$ref": "#/$defs/ContentBlock" + } + ], + "description": "The actual content block." + } + }, + "required": ["content"], + "type": "object" + }, + "ContentBlock": { + "description": "Content blocks represent displayable information in the Agent Client Protocol.\n\nThey provide a structured way to handle various types of user-facing content—whether\nit's text from language models, images for analysis, or embedded resources for context.\n\nContent blocks appear in:\n- User prompts sent via `session/prompt`\n- Language model output streamed through `session/update` notifications\n- Progress updates and results from tool calls\n\nThis structure is compatible with the Model Context Protocol (MCP), enabling\nagents to seamlessly forward content from MCP tool outputs without transformation.\n\nSee protocol docs: [Content](https://agentclientprotocol.com/protocol/content)", + "discriminator": { + "propertyName": "type" + }, + "oneOf": [ + { + "allOf": [ + { + "$ref": "#/$defs/TextContent" + } + ], + "description": "Text content. May be plain text or formatted with Markdown.\n\nAll agents MUST support text content blocks in prompts.\nClients SHOULD render this text as Markdown.", + "properties": { + "type": { + "const": "text", + "type": "string" + } + }, + "required": ["type"], + "type": "object" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ImageContent" + } + ], + "description": "Images for visual context or analysis.\n\nRequires the `image` prompt capability when included in prompts.", + "properties": { + "type": { + "const": "image", + "type": "string" + } + }, + "required": ["type"], + "type": "object" + }, + { + "allOf": [ + { + "$ref": "#/$defs/AudioContent" + } + ], + "description": "Audio data for transcription or analysis.\n\nRequires the `audio` prompt capability when included in prompts.", + "properties": { + "type": { + "const": "audio", + "type": "string" + } + }, + "required": ["type"], + "type": "object" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ResourceLink" + } + ], + "description": "References to resources that the agent can access.\n\nAll agents MUST support resource links in prompts.", + "properties": { + "type": { + "const": "resource_link", + "type": "string" + } + }, + "required": ["type"], + "type": "object" + }, + { + "allOf": [ + { + "$ref": "#/$defs/EmbeddedResource" + } + ], + "description": "Complete resource contents embedded directly in the message.\n\nPreferred for including context as it avoids extra round-trips.\n\nRequires the `embeddedContext` prompt capability when included in prompts.", + "properties": { + "type": { + "const": "resource", + "type": "string" + } + }, + "required": ["type"], + "type": "object" + } + ] + }, + "ContentChunk": { + "description": "A streamed item of content", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "content": { + "allOf": [ + { + "$ref": "#/$defs/ContentBlock" + } + ], + "description": "A single item of content" + } + }, + "required": ["content"], + "type": "object" + }, + "CreateTerminalRequest": { + "description": "Request to create a new terminal and execute a command.", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "args": { + "description": "Array of command arguments.", + "items": { + "type": "string" + }, + "type": "array" + }, + "command": { + "description": "The command to execute.", + "type": "string" + }, + "cwd": { + "description": "Working directory for the command (absolute path).", + "type": ["string", "null"] + }, + "env": { + "description": "Environment variables for the command.", + "items": { + "$ref": "#/$defs/EnvVariable" + }, + "type": "array" + }, + "outputByteLimit": { + "description": "Maximum number of output bytes to retain.\n\nWhen the limit is exceeded, the Client truncates from the beginning of the output\nto stay within the limit.\n\nThe Client MUST ensure truncation happens at a character boundary to maintain valid\nstring output, even if this means the retained output is slightly less than the\nspecified limit.", + "format": "uint64", + "minimum": 0, + "type": ["integer", "null"] + }, + "sessionId": { + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ], + "description": "The session ID for this request." + } + }, + "required": ["sessionId", "command"], + "type": "object", + "x-method": "terminal/create", + "x-side": "client" + }, + "CreateTerminalResponse": { + "description": "Response containing the ID of the created terminal.", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "terminalId": { + "description": "The unique identifier for the created terminal.", + "type": "string" + } + }, + "required": ["terminalId"], + "type": "object", + "x-method": "terminal/create", + "x-side": "client" + }, + "CurrentModeUpdate": { + "description": "The current mode of the session has changed\n\nSee protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes)", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "currentModeId": { + "allOf": [ + { + "$ref": "#/$defs/SessionModeId" + } + ], + "description": "The ID of the current mode" + } + }, + "required": ["currentModeId"], + "type": "object" + }, + "Diff": { + "description": "A diff representing file modifications.\n\nShows changes to files in a format suitable for display in the client UI.\n\nSee protocol docs: [Content](https://agentclientprotocol.com/protocol/tool-calls#content)", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "newText": { + "description": "The new content after modification.", + "type": "string" + }, + "oldText": { + "description": "The original content (None for new files).", + "type": ["string", "null"] + }, + "path": { + "description": "The file path being modified.", + "type": "string" + } + }, + "required": ["path", "newText"], + "type": "object" + }, + "EmbeddedResource": { + "description": "The contents of a resource, embedded into a prompt or tool call result.", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "annotations": { + "anyOf": [ + { + "$ref": "#/$defs/Annotations" + }, + { + "type": "null" + } + ] + }, + "resource": { + "$ref": "#/$defs/EmbeddedResourceResource" + } + }, + "required": ["resource"], + "type": "object" + }, + "EmbeddedResourceResource": { + "anyOf": [ + { + "allOf": [ + { + "$ref": "#/$defs/TextResourceContents" + } + ], + "title": "TextResourceContents" + }, + { + "allOf": [ + { + "$ref": "#/$defs/BlobResourceContents" + } + ], + "title": "BlobResourceContents" + } + ], + "description": "Resource content that can be embedded in a message." + }, + "EnvVariable": { + "description": "An environment variable to set when launching an MCP server.", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "name": { + "description": "The name of the environment variable.", + "type": "string" + }, + "value": { + "description": "The value to set for the environment variable.", + "type": "string" + } + }, + "required": ["name", "value"], + "type": "object" + }, + "Error": { + "description": "JSON-RPC error object.\n\nRepresents an error that occurred during method execution, following the\nJSON-RPC 2.0 error object specification with optional additional data.\n\nSee protocol docs: [JSON-RPC Error Object](https://www.jsonrpc.org/specification#error_object)", + "properties": { + "code": { + "allOf": [ + { + "$ref": "#/$defs/ErrorCode" + } + ], + "description": "A number indicating the error type that occurred.\nThis must be an integer as defined in the JSON-RPC specification." + }, + "data": { + "description": "Optional primitive or structured value that contains additional information about the error.\nThis may include debugging information or context-specific details." + }, + "message": { + "description": "A string providing a short description of the error.\nThe message should be limited to a concise single sentence.", + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "ErrorCode": { + "anyOf": [ + { + "const": -32700, + "description": "**Parse error**: Invalid JSON was received by the server.\nAn error occurred on the server while parsing the JSON text.", + "format": "int32", + "title": "Parse error", + "type": "integer" + }, + { + "const": -32600, + "description": "**Invalid request**: The JSON sent is not a valid Request object.", + "format": "int32", + "title": "Invalid request", + "type": "integer" + }, + { + "const": -32601, + "description": "**Method not found**: The method does not exist or is not available.", + "format": "int32", + "title": "Method not found", + "type": "integer" + }, + { + "const": -32602, + "description": "**Invalid params**: Invalid method parameter(s).", + "format": "int32", + "title": "Invalid params", + "type": "integer" + }, + { + "const": -32603, + "description": "**Internal error**: Internal JSON-RPC error.\nReserved for implementation-defined server errors.", + "format": "int32", + "title": "Internal error", + "type": "integer" + }, + { + "const": -32000, + "description": "**Authentication required**: Authentication is required before this operation can be performed.", + "format": "int32", + "title": "Authentication required", + "type": "integer" + }, + { + "const": -32002, + "description": "**Resource not found**: A given resource, such as a file, was not found.", + "format": "int32", + "title": "Resource not found", + "type": "integer" + }, + { + "description": "Other undefined error code.", + "format": "int32", + "title": "Other", + "type": "integer" + } + ], + "description": "Predefined error codes for common JSON-RPC and ACP-specific errors.\n\nThese codes follow the JSON-RPC 2.0 specification for standard errors\nand use the reserved range (-32000 to -32099) for protocol-specific errors." + }, + "ExtNotification": { + "description": "Allows the Agent to send an arbitrary notification that is not part of the ACP spec.\nExtension notifications provide a way to send one-way messages for custom functionality\nwhile maintaining protocol compatibility.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)" + }, + "ExtRequest": { + "description": "Allows for sending an arbitrary request that is not part of the ACP spec.\nExtension methods provide a way to add custom functionality while maintaining\nprotocol compatibility.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)" + }, + "ExtResponse": { + "description": "Allows for sending an arbitrary response to an [`ExtRequest`] that is not part of the ACP spec.\nExtension methods provide a way to add custom functionality while maintaining\nprotocol compatibility.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)" + }, + "FileSystemCapability": { + "description": "Filesystem capabilities supported by the client.\nFile system capabilities that a client may support.\n\nSee protocol docs: [FileSystem](https://agentclientprotocol.com/protocol/initialization#filesystem)", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "readTextFile": { + "default": false, + "description": "Whether the Client supports `fs/read_text_file` requests.", + "type": "boolean" + }, + "writeTextFile": { + "default": false, + "description": "Whether the Client supports `fs/write_text_file` requests.", + "type": "boolean" + } + }, + "type": "object" + }, + "HttpHeader": { + "description": "An HTTP header to set when making requests to the MCP server.", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "name": { + "description": "The name of the HTTP header.", + "type": "string" + }, + "value": { + "description": "The value to set for the HTTP header.", + "type": "string" + } + }, + "required": ["name", "value"], + "type": "object" + }, + "ImageContent": { + "description": "An image provided to or from an LLM.", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "annotations": { + "anyOf": [ + { + "$ref": "#/$defs/Annotations" + }, + { + "type": "null" + } + ] + }, + "data": { + "type": "string" + }, + "mimeType": { + "type": "string" + }, + "uri": { + "type": ["string", "null"] + } + }, + "required": ["data", "mimeType"], + "type": "object" + }, + "Implementation": { + "description": "Metadata about the implementation of the client or agent.\nDescribes the name and version of an MCP implementation, with an optional\ntitle for UI representation.", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "name": { + "description": "Intended for programmatic or logical use, but can be used as a display\nname fallback if title isn’t present.", + "type": "string" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable\nand easily understood.\n\nIf not provided, the name should be used for display.", + "type": ["string", "null"] + }, + "version": { + "description": "Version of the implementation. Can be displayed to the user or used\nfor debugging or metrics purposes. (e.g. \"1.0.0\").", + "type": "string" + } + }, + "required": ["name", "version"], + "type": "object" + }, + "InitializeRequest": { + "description": "Request parameters for the initialize method.\n\nSent by the client to establish connection and negotiate capabilities.\n\nSee protocol docs: [Initialization](https://agentclientprotocol.com/protocol/initialization)", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "clientCapabilities": { + "allOf": [ + { + "$ref": "#/$defs/ClientCapabilities" + } + ], + "default": { + "fs": { + "readTextFile": false, + "writeTextFile": false + }, + "terminal": false + }, + "description": "Capabilities supported by the client." + }, + "clientInfo": { + "anyOf": [ + { + "$ref": "#/$defs/Implementation" + }, + { + "type": "null" + } + ], + "description": "Information about the Client name and version sent to the Agent.\n\nNote: in future versions of the protocol, this will be required." + }, + "protocolVersion": { + "allOf": [ + { + "$ref": "#/$defs/ProtocolVersion" + } + ], + "description": "The latest protocol version supported by the client." + } + }, + "required": ["protocolVersion"], + "type": "object", + "x-method": "initialize", + "x-side": "agent" + }, + "InitializeResponse": { + "description": "Response to the `initialize` method.\n\nContains the negotiated protocol version and agent capabilities.\n\nSee protocol docs: [Initialization](https://agentclientprotocol.com/protocol/initialization)", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "agentCapabilities": { + "allOf": [ + { + "$ref": "#/$defs/AgentCapabilities" + } + ], + "default": { + "loadSession": false, + "mcpCapabilities": { + "http": false, + "sse": false + }, + "promptCapabilities": { + "audio": false, + "embeddedContext": false, + "image": false + }, + "sessionCapabilities": {} + }, + "description": "Capabilities supported by the agent." + }, + "agentInfo": { + "anyOf": [ + { + "$ref": "#/$defs/Implementation" + }, + { + "type": "null" + } + ], + "description": "Information about the Agent name and version sent to the Client.\n\nNote: in future versions of the protocol, this will be required." + }, + "authMethods": { + "default": [], + "description": "Authentication methods supported by the agent.", + "items": { + "$ref": "#/$defs/AuthMethod" + }, + "type": "array" + }, + "protocolVersion": { + "allOf": [ + { + "$ref": "#/$defs/ProtocolVersion" + } + ], + "description": "The protocol version the client specified if supported by the agent,\nor the latest protocol version supported by the agent.\n\nThe client should disconnect, if it doesn't support this version." + } + }, + "required": ["protocolVersion"], + "type": "object", + "x-method": "initialize", + "x-side": "agent" + }, + "KillTerminalCommandRequest": { + "description": "Request to kill a terminal command without releasing the terminal.", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "sessionId": { + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ], + "description": "The session ID for this request." + }, + "terminalId": { + "description": "The ID of the terminal to kill.", + "type": "string" + } + }, + "required": ["sessionId", "terminalId"], + "type": "object", + "x-method": "terminal/kill", + "x-side": "client" + }, + "KillTerminalCommandResponse": { + "description": "Response to terminal/kill command method", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + } + }, + "type": "object", + "x-method": "terminal/kill", + "x-side": "client" + }, + "LoadSessionRequest": { + "description": "Request parameters for loading an existing session.\n\nOnly available if the Agent supports the `loadSession` capability.\n\nSee protocol docs: [Loading Sessions](https://agentclientprotocol.com/protocol/session-setup#loading-sessions)", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "cwd": { + "description": "The working directory for this session.", + "type": "string" + }, + "mcpServers": { + "description": "List of MCP servers to connect to for this session.", + "items": { + "$ref": "#/$defs/McpServer" + }, + "type": "array" + }, + "sessionId": { + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ], + "description": "The ID of the session to load." + } + }, + "required": ["mcpServers", "cwd", "sessionId"], + "type": "object", + "x-method": "session/load", + "x-side": "agent" + }, + "LoadSessionResponse": { + "description": "Response from loading an existing session.", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "modes": { + "anyOf": [ + { + "$ref": "#/$defs/SessionModeState" + }, + { + "type": "null" + } + ], + "description": "Initial mode state if supported by the Agent\n\nSee protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes)" + } + }, + "type": "object", + "x-method": "session/load", + "x-side": "agent" + }, + "McpCapabilities": { + "description": "MCP capabilities supported by the agent", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "http": { + "default": false, + "description": "Agent supports [`McpServer::Http`].", + "type": "boolean" + }, + "sse": { + "default": false, + "description": "Agent supports [`McpServer::Sse`].", + "type": "boolean" + } + }, + "type": "object" + }, + "McpServer": { + "anyOf": [ + { + "allOf": [ + { + "$ref": "#/$defs/McpServerHttp" + } + ], + "description": "HTTP transport configuration\n\nOnly available when the Agent capabilities indicate `mcp_capabilities.http` is `true`.", + "properties": { + "type": { + "const": "http", + "type": "string" + } + }, + "required": ["type"], + "type": "object" + }, + { + "allOf": [ + { + "$ref": "#/$defs/McpServerSse" + } + ], + "description": "SSE transport configuration\n\nOnly available when the Agent capabilities indicate `mcp_capabilities.sse` is `true`.", + "properties": { + "type": { + "const": "sse", + "type": "string" + } + }, + "required": ["type"], + "type": "object" + }, + { + "allOf": [ + { + "$ref": "#/$defs/McpServerStdio" + } + ], + "description": "Stdio transport configuration\n\nAll Agents MUST support this transport.", + "title": "stdio" + } + ], + "description": "Configuration for connecting to an MCP (Model Context Protocol) server.\n\nMCP servers provide tools and context that the agent can use when\nprocessing prompts.\n\nSee protocol docs: [MCP Servers](https://agentclientprotocol.com/protocol/session-setup#mcp-servers)" + }, + "McpServerHttp": { + "description": "HTTP transport configuration for MCP.", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "headers": { + "description": "HTTP headers to set when making requests to the MCP server.", + "items": { + "$ref": "#/$defs/HttpHeader" + }, + "type": "array" + }, + "name": { + "description": "Human-readable name identifying this MCP server.", + "type": "string" + }, + "url": { + "description": "URL to the MCP server.", + "type": "string" + } + }, + "required": ["name", "url", "headers"], + "type": "object" + }, + "McpServerSse": { + "description": "SSE transport configuration for MCP.", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "headers": { + "description": "HTTP headers to set when making requests to the MCP server.", + "items": { + "$ref": "#/$defs/HttpHeader" + }, + "type": "array" + }, + "name": { + "description": "Human-readable name identifying this MCP server.", + "type": "string" + }, + "url": { + "description": "URL to the MCP server.", + "type": "string" + } + }, + "required": ["name", "url", "headers"], + "type": "object" + }, + "McpServerStdio": { + "description": "Stdio transport configuration for MCP.", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "args": { + "description": "Command-line arguments to pass to the MCP server.", + "items": { + "type": "string" + }, + "type": "array" + }, + "command": { + "description": "Path to the MCP server executable.", + "type": "string" + }, + "env": { + "description": "Environment variables to set when launching the MCP server.", + "items": { + "$ref": "#/$defs/EnvVariable" + }, + "type": "array" + }, + "name": { + "description": "Human-readable name identifying this MCP server.", + "type": "string" + } + }, + "required": ["name", "command", "args", "env"], + "type": "object" + }, + "NewSessionRequest": { + "description": "Request parameters for creating a new session.\n\nSee protocol docs: [Creating a Session](https://agentclientprotocol.com/protocol/session-setup#creating-a-session)", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "cwd": { + "description": "The working directory for this session. Must be an absolute path.", + "type": "string" + }, + "mcpServers": { + "description": "List of MCP (Model Context Protocol) servers the agent should connect to.", + "items": { + "$ref": "#/$defs/McpServer" + }, + "type": "array" + } + }, + "required": ["cwd", "mcpServers"], + "type": "object", + "x-method": "session/new", + "x-side": "agent" + }, + "NewSessionResponse": { + "description": "Response from creating a new session.\n\nSee protocol docs: [Creating a Session](https://agentclientprotocol.com/protocol/session-setup#creating-a-session)", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "modes": { + "anyOf": [ + { + "$ref": "#/$defs/SessionModeState" + }, + { + "type": "null" + } + ], + "description": "Initial mode state if supported by the Agent\n\nSee protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes)" + }, + "sessionId": { + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ], + "description": "Unique identifier for the created session.\n\nUsed in all subsequent requests for this conversation." + } + }, + "required": ["sessionId"], + "type": "object", + "x-method": "session/new", + "x-side": "agent" + }, + "PermissionOption": { + "description": "An option presented to the user when requesting permission.", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "kind": { + "allOf": [ + { + "$ref": "#/$defs/PermissionOptionKind" + } + ], + "description": "Hint about the nature of this permission option." + }, + "name": { + "description": "Human-readable label to display to the user.", + "type": "string" + }, + "optionId": { + "allOf": [ + { + "$ref": "#/$defs/PermissionOptionId" + } + ], + "description": "Unique identifier for this permission option." + } + }, + "required": ["optionId", "name", "kind"], + "type": "object" + }, + "PermissionOptionId": { + "description": "Unique identifier for a permission option.", + "type": "string" + }, + "PermissionOptionKind": { + "description": "The type of permission option being presented to the user.\n\nHelps clients choose appropriate icons and UI treatment.", + "oneOf": [ + { + "const": "allow_once", + "description": "Allow this operation only this time.", + "type": "string" + }, + { + "const": "allow_always", + "description": "Allow this operation and remember the choice.", + "type": "string" + }, + { + "const": "reject_once", + "description": "Reject this operation only this time.", + "type": "string" + }, + { + "const": "reject_always", + "description": "Reject this operation and remember the choice.", + "type": "string" + } + ] + }, + "Plan": { + "description": "An execution plan for accomplishing complex tasks.\n\nPlans consist of multiple entries representing individual tasks or goals.\nAgents report plans to clients to provide visibility into their execution strategy.\nPlans can evolve during execution as the agent discovers new requirements or completes tasks.\n\nSee protocol docs: [Agent Plan](https://agentclientprotocol.com/protocol/agent-plan)", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "entries": { + "description": "The list of tasks to be accomplished.\n\nWhen updating a plan, the agent must send a complete list of all entries\nwith their current status. The client replaces the entire plan with each update.", + "items": { + "$ref": "#/$defs/PlanEntry" + }, + "type": "array" + } + }, + "required": ["entries"], + "type": "object" + }, + "PlanEntry": { + "description": "A single entry in the execution plan.\n\nRepresents a task or goal that the assistant intends to accomplish\nas part of fulfilling the user's request.\nSee protocol docs: [Plan Entries](https://agentclientprotocol.com/protocol/agent-plan#plan-entries)", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "content": { + "description": "Human-readable description of what this task aims to accomplish.", + "type": "string" + }, + "priority": { + "allOf": [ + { + "$ref": "#/$defs/PlanEntryPriority" + } + ], + "description": "The relative importance of this task.\nUsed to indicate which tasks are most critical to the overall goal." + }, + "status": { + "allOf": [ + { + "$ref": "#/$defs/PlanEntryStatus" + } + ], + "description": "Current execution status of this task." + } + }, + "required": ["content", "priority", "status"], + "type": "object" + }, + "PlanEntryPriority": { + "description": "Priority levels for plan entries.\n\nUsed to indicate the relative importance or urgency of different\ntasks in the execution plan.\nSee protocol docs: [Plan Entries](https://agentclientprotocol.com/protocol/agent-plan#plan-entries)", + "oneOf": [ + { + "const": "high", + "description": "High priority task - critical to the overall goal.", + "type": "string" + }, + { + "const": "medium", + "description": "Medium priority task - important but not critical.", + "type": "string" + }, + { + "const": "low", + "description": "Low priority task - nice to have but not essential.", + "type": "string" + } + ] + }, + "PlanEntryStatus": { + "description": "Status of a plan entry in the execution flow.\n\nTracks the lifecycle of each task from planning through completion.\nSee protocol docs: [Plan Entries](https://agentclientprotocol.com/protocol/agent-plan#plan-entries)", + "oneOf": [ + { + "const": "pending", + "description": "The task has not started yet.", + "type": "string" + }, + { + "const": "in_progress", + "description": "The task is currently being worked on.", + "type": "string" + }, + { + "const": "completed", + "description": "The task has been successfully completed.", + "type": "string" + } + ] + }, + "PromptCapabilities": { + "description": "Prompt capabilities supported by the agent in `session/prompt` requests.\n\nBaseline agent functionality requires support for [`ContentBlock::Text`]\nand [`ContentBlock::ResourceLink`] in prompt requests.\n\nOther variants must be explicitly opted in to.\nCapabilities for different types of content in prompt requests.\n\nIndicates which content types beyond the baseline (text and resource links)\nthe agent can process.\n\nSee protocol docs: [Prompt Capabilities](https://agentclientprotocol.com/protocol/initialization#prompt-capabilities)", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "audio": { + "default": false, + "description": "Agent supports [`ContentBlock::Audio`].", + "type": "boolean" + }, + "embeddedContext": { + "default": false, + "description": "Agent supports embedded context in `session/prompt` requests.\n\nWhen enabled, the Client is allowed to include [`ContentBlock::Resource`]\nin prompt requests for pieces of context that are referenced in the message.", + "type": "boolean" + }, + "image": { + "default": false, + "description": "Agent supports [`ContentBlock::Image`].", + "type": "boolean" + } + }, + "type": "object" + }, + "PromptRequest": { + "description": "Request parameters for sending a user prompt to the agent.\n\nContains the user's message and any additional context.\n\nSee protocol docs: [User Message](https://agentclientprotocol.com/protocol/prompt-turn#1-user-message)", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "prompt": { + "description": "The blocks of content that compose the user's message.\n\nAs a baseline, the Agent MUST support [`ContentBlock::Text`] and [`ContentBlock::ResourceLink`],\nwhile other variants are optionally enabled via [`PromptCapabilities`].\n\nThe Client MUST adapt its interface according to [`PromptCapabilities`].\n\nThe client MAY include referenced pieces of context as either\n[`ContentBlock::Resource`] or [`ContentBlock::ResourceLink`].\n\nWhen available, [`ContentBlock::Resource`] is preferred\nas it avoids extra round-trips and allows the message to include\npieces of context from sources the agent may not have access to.", + "items": { + "$ref": "#/$defs/ContentBlock" + }, + "type": "array" + }, + "sessionId": { + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ], + "description": "The ID of the session to send this user message to" + } + }, + "required": ["sessionId", "prompt"], + "type": "object", + "x-method": "session/prompt", + "x-side": "agent" + }, + "PromptResponse": { + "description": "Response from processing a user prompt.\n\nSee protocol docs: [Check for Completion](https://agentclientprotocol.com/protocol/prompt-turn#4-check-for-completion)", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "stopReason": { + "allOf": [ + { + "$ref": "#/$defs/StopReason" + } + ], + "description": "Indicates why the agent stopped processing the turn." + } + }, + "required": ["stopReason"], + "type": "object", + "x-method": "session/prompt", + "x-side": "agent" + }, + "ProtocolVersion": { + "description": "Protocol version identifier.\n\nThis version is only bumped for breaking changes.\nNon-breaking changes should be introduced via capabilities.", + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": "integer" + }, + "ReadTextFileRequest": { + "description": "Request to read content from a text file.\n\nOnly available if the client supports the `fs.readTextFile` capability.", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "limit": { + "description": "Maximum number of lines to read.", + "format": "uint32", + "minimum": 0, + "type": ["integer", "null"] + }, + "line": { + "description": "Line number to start reading from (1-based).", + "format": "uint32", + "minimum": 0, + "type": ["integer", "null"] + }, + "path": { + "description": "Absolute path to the file to read.", + "type": "string" + }, + "sessionId": { + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ], + "description": "The session ID for this request." + } + }, + "required": ["sessionId", "path"], + "type": "object", + "x-method": "fs/read_text_file", + "x-side": "client" + }, + "ReadTextFileResponse": { + "description": "Response containing the contents of a text file.", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "content": { + "type": "string" + } + }, + "required": ["content"], + "type": "object", + "x-method": "fs/read_text_file", + "x-side": "client" + }, + "ReleaseTerminalRequest": { + "description": "Request to release a terminal and free its resources.", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "sessionId": { + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ], + "description": "The session ID for this request." + }, + "terminalId": { + "description": "The ID of the terminal to release.", + "type": "string" + } + }, + "required": ["sessionId", "terminalId"], + "type": "object", + "x-method": "terminal/release", + "x-side": "client" + }, + "ReleaseTerminalResponse": { + "description": "Response to terminal/release method", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + } + }, + "type": "object", + "x-method": "terminal/release", + "x-side": "client" + }, + "RequestId": { + "anyOf": [ + { + "title": "Null", + "type": "null" + }, + { + "format": "int64", + "title": "Number", + "type": "integer" + }, + { + "title": "Str", + "type": "string" + } + ], + "description": "JSON RPC Request Id\n\nAn identifier established by the Client that MUST contain a String, Number, or NULL value if included. If it is not included it is assumed to be a notification. The value SHOULD normally not be Null [1] and Numbers SHOULD NOT contain fractional parts [2]\n\nThe Server MUST reply with the same value in the Response object if included. This member is used to correlate the context between the two objects.\n\n[1] The use of Null as a value for the id member in a Request object is discouraged, because this specification uses a value of Null for Responses with an unknown id. Also, because JSON-RPC 1.0 uses an id value of Null for Notifications this could cause confusion in handling.\n\n[2] Fractional parts may be problematic, since many decimal fractions cannot be represented exactly as binary fractions." + }, + "RequestPermissionOutcome": { + "description": "The outcome of a permission request.", + "discriminator": { + "propertyName": "outcome" + }, + "oneOf": [ + { + "description": "The prompt turn was cancelled before the user responded.\n\nWhen a client sends a `session/cancel` notification to cancel an ongoing\nprompt turn, it MUST respond to all pending `session/request_permission`\nrequests with this `Cancelled` outcome.\n\nSee protocol docs: [Cancellation](https://agentclientprotocol.com/protocol/prompt-turn#cancellation)", + "properties": { + "outcome": { + "const": "cancelled", + "type": "string" + } + }, + "required": ["outcome"], + "type": "object" + }, + { + "allOf": [ + { + "$ref": "#/$defs/SelectedPermissionOutcome" + } + ], + "description": "The user selected one of the provided options.", + "properties": { + "outcome": { + "const": "selected", + "type": "string" + } + }, + "required": ["outcome"], + "type": "object" + } + ] + }, + "RequestPermissionRequest": { + "description": "Request for user permission to execute a tool call.\n\nSent when the agent needs authorization before performing a sensitive operation.\n\nSee protocol docs: [Requesting Permission](https://agentclientprotocol.com/protocol/tool-calls#requesting-permission)", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "options": { + "description": "Available permission options for the user to choose from.", + "items": { + "$ref": "#/$defs/PermissionOption" + }, + "type": "array" + }, + "sessionId": { + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ], + "description": "The session ID for this request." + }, + "toolCall": { + "allOf": [ + { + "$ref": "#/$defs/ToolCallUpdate" + } + ], + "description": "Details about the tool call requiring permission." + } + }, + "required": ["sessionId", "toolCall", "options"], + "type": "object", + "x-method": "session/request_permission", + "x-side": "client" + }, + "RequestPermissionResponse": { + "description": "Response to a permission request.", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "outcome": { + "allOf": [ + { + "$ref": "#/$defs/RequestPermissionOutcome" + } + ], + "description": "The user's decision on the permission request." + } + }, + "required": ["outcome"], + "type": "object", + "x-method": "session/request_permission", + "x-side": "client" + }, + "ResourceLink": { + "description": "A resource that the server is capable of reading, included in a prompt or tool call result.", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "annotations": { + "anyOf": [ + { + "$ref": "#/$defs/Annotations" + }, + { + "type": "null" + } + ] + }, + "description": { + "type": ["string", "null"] + }, + "mimeType": { + "type": ["string", "null"] + }, + "name": { + "type": "string" + }, + "size": { + "format": "int64", + "type": ["integer", "null"] + }, + "title": { + "type": ["string", "null"] + }, + "uri": { + "type": "string" + } + }, + "required": ["name", "uri"], + "type": "object" + }, + "Role": { + "description": "The sender or recipient of messages and data in a conversation.", + "enum": ["assistant", "user"], + "type": "string" + }, + "SelectedPermissionOutcome": { + "description": "The user selected one of the provided options.", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "optionId": { + "allOf": [ + { + "$ref": "#/$defs/PermissionOptionId" + } + ], + "description": "The ID of the option the user selected." + } + }, + "required": ["optionId"], + "type": "object" + }, + "SessionCapabilities": { + "description": "Session capabilities supported by the agent.\n\nAs a baseline, all Agents **MUST** support `session/new`, `session/prompt`, `session/cancel`, and `session/update`.\n\nOptionally, they **MAY** support other session methods and notifications by specifying additional capabilities.\n\nNote: `session/load` is still handled by the top-level `load_session` capability. This will be unified in future versions of the protocol.\n\nSee protocol docs: [Session Capabilities](https://agentclientprotocol.com/protocol/initialization#session-capabilities)", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + } + }, + "type": "object" + }, + "SessionId": { + "description": "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)", + "type": "string" + }, + "SessionMode": { + "description": "A mode the agent can operate in.\n\nSee protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes)", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "description": { + "type": ["string", "null"] + }, + "id": { + "$ref": "#/$defs/SessionModeId" + }, + "name": { + "type": "string" + } + }, + "required": ["id", "name"], + "type": "object" + }, + "SessionModeId": { + "description": "Unique identifier for a Session Mode.", + "type": "string" + }, + "SessionModeState": { + "description": "The set of modes and the one currently active.", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "availableModes": { + "description": "The set of modes that the Agent can operate in", + "items": { + "$ref": "#/$defs/SessionMode" + }, + "type": "array" + }, + "currentModeId": { + "allOf": [ + { + "$ref": "#/$defs/SessionModeId" + } + ], + "description": "The current mode the Agent is in." + } + }, + "required": ["currentModeId", "availableModes"], + "type": "object" + }, + "SessionNotification": { + "description": "Notification containing a session update from the agent.\n\nUsed to stream real-time progress and results during prompt processing.\n\nSee protocol docs: [Agent Reports Output](https://agentclientprotocol.com/protocol/prompt-turn#3-agent-reports-output)", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "sessionId": { + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ], + "description": "The ID of the session this update pertains to." + }, + "update": { + "allOf": [ + { + "$ref": "#/$defs/SessionUpdate" + } + ], + "description": "The actual update content." + } + }, + "required": ["sessionId", "update"], + "type": "object", + "x-method": "session/update", + "x-side": "client" + }, + "SessionUpdate": { + "description": "Different types of updates that can be sent during session processing.\n\nThese updates provide real-time feedback about the agent's progress.\n\nSee protocol docs: [Agent Reports Output](https://agentclientprotocol.com/protocol/prompt-turn#3-agent-reports-output)", + "discriminator": { + "propertyName": "sessionUpdate" + }, + "oneOf": [ + { + "allOf": [ + { + "$ref": "#/$defs/ContentChunk" + } + ], + "description": "A chunk of the user's message being streamed.", + "properties": { + "sessionUpdate": { + "const": "user_message_chunk", + "type": "string" + } + }, + "required": ["sessionUpdate"], + "type": "object" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ContentChunk" + } + ], + "description": "A chunk of the agent's response being streamed.", + "properties": { + "sessionUpdate": { + "const": "agent_message_chunk", + "type": "string" + } + }, + "required": ["sessionUpdate"], + "type": "object" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ContentChunk" + } + ], + "description": "A chunk of the agent's internal reasoning being streamed.", + "properties": { + "sessionUpdate": { + "const": "agent_thought_chunk", + "type": "string" + } + }, + "required": ["sessionUpdate"], + "type": "object" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ToolCall" + } + ], + "description": "Notification that a new tool call has been initiated.", + "properties": { + "sessionUpdate": { + "const": "tool_call", + "type": "string" + } + }, + "required": ["sessionUpdate"], + "type": "object" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ToolCallUpdate" + } + ], + "description": "Update on the status or results of a tool call.", + "properties": { + "sessionUpdate": { + "const": "tool_call_update", + "type": "string" + } + }, + "required": ["sessionUpdate"], + "type": "object" + }, + { + "allOf": [ + { + "$ref": "#/$defs/Plan" + } + ], + "description": "The agent's execution plan for complex tasks.\nSee protocol docs: [Agent Plan](https://agentclientprotocol.com/protocol/agent-plan)", + "properties": { + "sessionUpdate": { + "const": "plan", + "type": "string" + } + }, + "required": ["sessionUpdate"], + "type": "object" + }, + { + "allOf": [ + { + "$ref": "#/$defs/AvailableCommandsUpdate" + } + ], + "description": "Available commands are ready or have changed", + "properties": { + "sessionUpdate": { + "const": "available_commands_update", + "type": "string" + } + }, + "required": ["sessionUpdate"], + "type": "object" + }, + { + "allOf": [ + { + "$ref": "#/$defs/CurrentModeUpdate" + } + ], + "description": "The current mode of the session has changed\n\nSee protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes)", + "properties": { + "sessionUpdate": { + "const": "current_mode_update", + "type": "string" + } + }, + "required": ["sessionUpdate"], + "type": "object" + } + ] + }, + "SetSessionModeRequest": { + "description": "Request parameters for setting a session mode.", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "modeId": { + "allOf": [ + { + "$ref": "#/$defs/SessionModeId" + } + ], + "description": "The ID of the mode to set." + }, + "sessionId": { + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ], + "description": "The ID of the session to set the mode for." + } + }, + "required": ["sessionId", "modeId"], + "type": "object", + "x-method": "session/set_mode", + "x-side": "agent" + }, + "SetSessionModeResponse": { + "description": "Response to `session/set_mode` method.", + "properties": { + "_meta": { + "additionalProperties": true, + "type": ["object", "null"] + } + }, + "type": "object", + "x-method": "session/set_mode", + "x-side": "agent" + }, + "StopReason": { + "description": "Reasons why an agent stops processing a prompt turn.\n\nSee protocol docs: [Stop Reasons](https://agentclientprotocol.com/protocol/prompt-turn#stop-reasons)", + "oneOf": [ + { + "const": "end_turn", + "description": "The turn ended successfully.", + "type": "string" + }, + { + "const": "max_tokens", + "description": "The turn ended because the agent reached the maximum number of tokens.", + "type": "string" + }, + { + "const": "max_turn_requests", + "description": "The turn ended because the agent reached the maximum number of allowed\nagent requests between user turns.", + "type": "string" + }, + { + "const": "refusal", + "description": "The turn ended because the agent refused to continue. The user prompt\nand everything that comes after it won't be included in the next\nprompt, so this should be reflected in the UI.", + "type": "string" + }, + { + "const": "cancelled", + "description": "The turn was cancelled by the client via `session/cancel`.\n\nThis stop reason MUST be returned when the client sends a `session/cancel`\nnotification, even if the cancellation causes exceptions in underlying operations.\nAgents should catch these exceptions and return this semantically meaningful\nresponse to confirm successful cancellation.", + "type": "string" + } + ] + }, + "Terminal": { + "description": "Embed a terminal created with `terminal/create` by its id.\n\nThe terminal must be added before calling `terminal/release`.\n\nSee protocol docs: [Terminal](https://agentclientprotocol.com/protocol/terminals)", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "terminalId": { + "type": "string" + } + }, + "required": ["terminalId"], + "type": "object" + }, + "TerminalExitStatus": { + "description": "Exit status of a terminal command.", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "exitCode": { + "description": "The process exit code (may be null if terminated by signal).", + "format": "uint32", + "minimum": 0, + "type": ["integer", "null"] + }, + "signal": { + "description": "The signal that terminated the process (may be null if exited normally).", + "type": ["string", "null"] + } + }, + "type": "object" + }, + "TerminalOutputRequest": { + "description": "Request to get the current output and status of a terminal.", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "sessionId": { + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ], + "description": "The session ID for this request." + }, + "terminalId": { + "description": "The ID of the terminal to get output from.", + "type": "string" + } + }, + "required": ["sessionId", "terminalId"], + "type": "object", + "x-method": "terminal/output", + "x-side": "client" + }, + "TerminalOutputResponse": { + "description": "Response containing the terminal output and exit status.", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "exitStatus": { + "anyOf": [ + { + "$ref": "#/$defs/TerminalExitStatus" + }, + { + "type": "null" + } + ], + "description": "Exit status if the command has completed." + }, + "output": { + "description": "The terminal output captured so far.", + "type": "string" + }, + "truncated": { + "description": "Whether the output was truncated due to byte limits.", + "type": "boolean" + } + }, + "required": ["output", "truncated"], + "type": "object", + "x-method": "terminal/output", + "x-side": "client" + }, + "TextContent": { + "description": "Text provided to or from an LLM.", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "annotations": { + "anyOf": [ + { + "$ref": "#/$defs/Annotations" + }, + { + "type": "null" + } + ] + }, + "text": { + "type": "string" + } + }, + "required": ["text"], + "type": "object" + }, + "TextResourceContents": { + "description": "Text-based resource contents.", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "mimeType": { + "type": ["string", "null"] + }, + "text": { + "type": "string" + }, + "uri": { + "type": "string" + } + }, + "required": ["text", "uri"], + "type": "object" + }, + "ToolCall": { + "description": "Represents a tool call that the language model has requested.\n\nTool calls are actions that the agent executes on behalf of the language model,\nsuch as reading files, executing code, or fetching data from external sources.\n\nSee protocol docs: [Tool Calls](https://agentclientprotocol.com/protocol/tool-calls)", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "content": { + "description": "Content produced by the tool call.", + "items": { + "$ref": "#/$defs/ToolCallContent" + }, + "type": "array" + }, + "kind": { + "allOf": [ + { + "$ref": "#/$defs/ToolKind" + } + ], + "description": "The category of tool being invoked.\nHelps clients choose appropriate icons and UI treatment." + }, + "locations": { + "description": "File locations affected by this tool call.\nEnables \"follow-along\" features in clients.", + "items": { + "$ref": "#/$defs/ToolCallLocation" + }, + "type": "array" + }, + "rawInput": { + "description": "Raw input parameters sent to the tool." + }, + "rawOutput": { + "description": "Raw output returned by the tool." + }, + "status": { + "allOf": [ + { + "$ref": "#/$defs/ToolCallStatus" + } + ], + "description": "Current execution status of the tool call." + }, + "title": { + "description": "Human-readable title describing what the tool is doing.", + "type": "string" + }, + "toolCallId": { + "allOf": [ + { + "$ref": "#/$defs/ToolCallId" + } + ], + "description": "Unique identifier for this tool call within the session." + } + }, + "required": ["toolCallId", "title"], + "type": "object" + }, + "ToolCallContent": { + "description": "Content produced by a tool call.\n\nTool calls can produce different types of content including\nstandard content blocks (text, images) or file diffs.\n\nSee protocol docs: [Content](https://agentclientprotocol.com/protocol/tool-calls#content)", + "discriminator": { + "propertyName": "type" + }, + "oneOf": [ + { + "allOf": [ + { + "$ref": "#/$defs/Content" + } + ], + "description": "Standard content block (text, images, resources).", + "properties": { + "type": { + "const": "content", + "type": "string" + } + }, + "required": ["type"], + "type": "object" + }, + { + "allOf": [ + { + "$ref": "#/$defs/Diff" + } + ], + "description": "File modification shown as a diff.", + "properties": { + "type": { + "const": "diff", + "type": "string" + } + }, + "required": ["type"], + "type": "object" + }, + { + "allOf": [ + { + "$ref": "#/$defs/Terminal" + } + ], + "description": "Embed a terminal created with `terminal/create` by its id.\n\nThe terminal must be added before calling `terminal/release`.\n\nSee protocol docs: [Terminal](https://agentclientprotocol.com/protocol/terminals)", + "properties": { + "type": { + "const": "terminal", + "type": "string" + } + }, + "required": ["type"], + "type": "object" + } + ] + }, + "ToolCallId": { + "description": "Unique identifier for a tool call within a session.", + "type": "string" + }, + "ToolCallLocation": { + "description": "A file location being accessed or modified by a tool.\n\nEnables clients to implement \"follow-along\" features that track\nwhich files the agent is working with in real-time.\n\nSee protocol docs: [Following the Agent](https://agentclientprotocol.com/protocol/tool-calls#following-the-agent)", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "line": { + "description": "Optional line number within the file.", + "format": "uint32", + "minimum": 0, + "type": ["integer", "null"] + }, + "path": { + "description": "The file path being accessed or modified.", + "type": "string" + } + }, + "required": ["path"], + "type": "object" + }, + "ToolCallStatus": { + "description": "Execution status of a tool call.\n\nTool calls progress through different statuses during their lifecycle.\n\nSee protocol docs: [Status](https://agentclientprotocol.com/protocol/tool-calls#status)", + "oneOf": [ + { + "const": "pending", + "description": "The tool call hasn't started running yet because the input is either\nstreaming or we're awaiting approval.", + "type": "string" + }, + { + "const": "in_progress", + "description": "The tool call is currently running.", + "type": "string" + }, + { + "const": "completed", + "description": "The tool call completed successfully.", + "type": "string" + }, + { + "const": "failed", + "description": "The tool call failed with an error.", + "type": "string" + } + ] + }, + "ToolCallUpdate": { + "description": "An update to an existing tool call.\n\nUsed to report progress and results as tools execute. All fields except\nthe tool call ID are optional - only changed fields need to be included.\n\nSee protocol docs: [Updating](https://agentclientprotocol.com/protocol/tool-calls#updating)", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "content": { + "description": "Replace the content collection.", + "items": { + "$ref": "#/$defs/ToolCallContent" + }, + "type": ["array", "null"] + }, + "kind": { + "anyOf": [ + { + "$ref": "#/$defs/ToolKind" + }, + { + "type": "null" + } + ], + "description": "Update the tool kind." + }, + "locations": { + "description": "Replace the locations collection.", + "items": { + "$ref": "#/$defs/ToolCallLocation" + }, + "type": ["array", "null"] + }, + "rawInput": { + "description": "Update the raw input." + }, + "rawOutput": { + "description": "Update the raw output." + }, + "status": { + "anyOf": [ + { + "$ref": "#/$defs/ToolCallStatus" + }, + { + "type": "null" + } + ], + "description": "Update the execution status." + }, + "title": { + "description": "Update the human-readable title.", + "type": ["string", "null"] + }, + "toolCallId": { + "allOf": [ + { + "$ref": "#/$defs/ToolCallId" + } + ], + "description": "The ID of the tool call being updated." + } + }, + "required": ["toolCallId"], + "type": "object" + }, + "ToolKind": { + "description": "Categories of tools that can be invoked.\n\nTool kinds help clients choose appropriate icons and optimize how they\ndisplay tool execution progress.\n\nSee protocol docs: [Creating](https://agentclientprotocol.com/protocol/tool-calls#creating)", + "oneOf": [ + { + "const": "read", + "description": "Reading files or data.", + "type": "string" + }, + { + "const": "edit", + "description": "Modifying files or content.", + "type": "string" + }, + { + "const": "delete", + "description": "Removing files or data.", + "type": "string" + }, + { + "const": "move", + "description": "Moving or renaming files.", + "type": "string" + }, + { + "const": "search", + "description": "Searching for information.", + "type": "string" + }, + { + "const": "execute", + "description": "Running commands or code.", + "type": "string" + }, + { + "const": "think", + "description": "Internal reasoning or planning.", + "type": "string" + }, + { + "const": "fetch", + "description": "Retrieving external data.", + "type": "string" + }, + { + "const": "switch_mode", + "description": "Switching the current session mode.", + "type": "string" + }, + { + "const": "other", + "description": "Other tool types (default).", + "type": "string" + } + ] + }, + "UnstructuredCommandInput": { + "description": "All text that was typed after the command name is provided as input.", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "hint": { + "description": "A hint to display when the input hasn't been provided yet", + "type": "string" + } + }, + "required": ["hint"], + "type": "object" + }, + "WaitForTerminalExitRequest": { + "description": "Request to wait for a terminal command to exit.", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "sessionId": { + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ], + "description": "The session ID for this request." + }, + "terminalId": { + "description": "The ID of the terminal to wait for.", + "type": "string" + } + }, + "required": ["sessionId", "terminalId"], + "type": "object", + "x-method": "terminal/wait_for_exit", + "x-side": "client" + }, + "WaitForTerminalExitResponse": { + "description": "Response containing the exit status of a terminal command.", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "exitCode": { + "description": "The process exit code (may be null if terminated by signal).", + "format": "uint32", + "minimum": 0, + "type": ["integer", "null"] + }, + "signal": { + "description": "The signal that terminated the process (may be null if exited normally).", + "type": ["string", "null"] + } + }, + "type": "object", + "x-method": "terminal/wait_for_exit", + "x-side": "client" + }, + "WriteTextFileRequest": { + "description": "Request to write content to a text file.\n\nOnly available if the client supports the `fs.writeTextFile` capability.", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "content": { + "description": "The text content to write to the file.", + "type": "string" + }, + "path": { + "description": "Absolute path to the file to write.", + "type": "string" + }, + "sessionId": { + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ], + "description": "The session ID for this request." + } + }, + "required": ["sessionId", "path", "content"], + "type": "object", + "x-method": "fs/write_text_file", + "x-side": "client" + }, + "WriteTextFileResponse": { + "description": "Response to `fs/write_text_file`", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + } + }, + "type": "object", + "x-method": "fs/write_text_file", + "x-side": "client" + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "anyOf": [ + { + "anyOf": [ + { + "allOf": [ + { + "$ref": "#/$defs/AgentRequest" + } + ], + "title": "Request" + }, + { + "allOf": [ + { + "$ref": "#/$defs/AgentResponse" + } + ], + "title": "Response" + }, + { + "allOf": [ + { + "$ref": "#/$defs/AgentNotification" + } + ], + "title": "Notification" + } + ], + "description": "A message (request, response, or notification) with `\"jsonrpc\": \"2.0\"` specified as\n[required by JSON-RPC 2.0 Specification][1].\n\n[1]: https://www.jsonrpc.org/specification#compatibility", + "properties": { + "jsonrpc": { + "enum": ["2.0"], + "type": "string" + } + }, + "required": ["jsonrpc"], + "title": "Agent", + "type": "object" + }, + { + "anyOf": [ + { + "allOf": [ + { + "$ref": "#/$defs/ClientRequest" + } + ], + "title": "Request" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ClientResponse" + } + ], + "title": "Response" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ClientNotification" + } + ], + "title": "Notification" + } + ], + "description": "A message (request, response, or notification) with `\"jsonrpc\": \"2.0\"` specified as\n[required by JSON-RPC 2.0 Specification][1].\n\n[1]: https://www.jsonrpc.org/specification#compatibility", + "properties": { + "jsonrpc": { + "enum": ["2.0"], + "type": "string" + } + }, + "required": ["jsonrpc"], + "title": "Client", + "type": "object" + } + ], + "title": "Agent Client Protocol" +} diff --git a/packages/sdk-java/qwencode/.editorconfig b/packages/sdk-java/qwencode/.editorconfig new file mode 100644 index 000000000..53a4241f9 --- /dev/null +++ b/packages/sdk-java/qwencode/.editorconfig @@ -0,0 +1,24 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 +tab_width = 4 +ij_continuation_indent_size = 8 + +[*.java] +ij_java_doc_align_exception_comments = false +ij_java_doc_align_param_comments = false + +[*.{yaml, yml, sh, ps1}] +indent_size = 2 + +[*.{md, mkd, markdown}] +trim_trailing_whitespace = false + +[{**/res/**.xml, **/AndroidManifest.xml}] +ij_continuation_indent_size = 4 diff --git a/packages/sdk-java/.gitignore b/packages/sdk-java/qwencode/.gitignore similarity index 100% rename from packages/sdk-java/.gitignore rename to packages/sdk-java/qwencode/.gitignore diff --git a/packages/sdk-java/LICENSE b/packages/sdk-java/qwencode/LICENSE similarity index 100% rename from packages/sdk-java/LICENSE rename to packages/sdk-java/qwencode/LICENSE diff --git a/packages/sdk-java/QWEN.md b/packages/sdk-java/qwencode/QWEN.md similarity index 100% rename from packages/sdk-java/QWEN.md rename to packages/sdk-java/qwencode/QWEN.md diff --git a/packages/sdk-java/README.md b/packages/sdk-java/qwencode/README.md similarity index 98% rename from packages/sdk-java/README.md rename to packages/sdk-java/qwencode/README.md index 772c93742..02516c9c3 100644 --- a/packages/sdk-java/README.md +++ b/packages/sdk-java/qwencode/README.md @@ -283,10 +283,6 @@ The SDK provides specific exception types for different error scenarios: ## FAQ / Troubleshooting -### Q: Do I need to install the Qwen CLI separately? - -A: No, from v0.1.1, the CLI is bundled with the SDK, so no standalone CLI installation is needed. - ### Q: What Java versions are supported? A: The SDK requires Java 1.8 or higher. diff --git a/packages/sdk-java/RELEASE.md b/packages/sdk-java/qwencode/RELEASE.md similarity index 100% rename from packages/sdk-java/RELEASE.md rename to packages/sdk-java/qwencode/RELEASE.md diff --git a/packages/sdk-java/qwencode/checkstyle.xml b/packages/sdk-java/qwencode/checkstyle.xml new file mode 100644 index 000000000..c67c1319f --- /dev/null +++ b/packages/sdk-java/qwencode/checkstyle.xml @@ -0,0 +1,131 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/sdk-java/pom.xml b/packages/sdk-java/qwencode/pom.xml similarity index 99% rename from packages/sdk-java/pom.xml rename to packages/sdk-java/qwencode/pom.xml index 9ce3df0d5..7defb5926 100644 --- a/packages/sdk-java/pom.xml +++ b/packages/sdk-java/qwencode/pom.xml @@ -5,7 +5,7 @@ com.alibaba qwencode-sdk jar - 0.0.2-alpha + 0.0.3-alpha qwencode-sdk The Qwen Code Java SDK is a minimum experimental SDK for programmatic access to Qwen Code functionality. It provides a Java interface to interact with the Qwen Code CLI, allowing developers to integrate Qwen Code capabilities into their Java applications. diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/QwenCodeCli.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/QwenCodeCli.java similarity index 100% rename from packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/QwenCodeCli.java rename to packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/QwenCodeCli.java diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/AssistantContent.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/data/AssistantContent.java similarity index 100% rename from packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/AssistantContent.java rename to packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/data/AssistantContent.java diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/AssistantUsage.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/data/AssistantUsage.java similarity index 100% rename from packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/AssistantUsage.java rename to packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/data/AssistantUsage.java diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/CLIPermissionDenial.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/data/CLIPermissionDenial.java similarity index 100% rename from packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/CLIPermissionDenial.java rename to packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/data/CLIPermissionDenial.java diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/Capabilities.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/data/Capabilities.java similarity index 100% rename from packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/Capabilities.java rename to packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/data/Capabilities.java diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/ExtendedUsage.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/data/ExtendedUsage.java similarity index 100% rename from packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/ExtendedUsage.java rename to packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/data/ExtendedUsage.java diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/InitializeConfig.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/data/InitializeConfig.java similarity index 100% rename from packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/InitializeConfig.java rename to packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/data/InitializeConfig.java diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/ModelUsage.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/data/ModelUsage.java similarity index 100% rename from packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/ModelUsage.java rename to packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/data/ModelUsage.java diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/PermissionMode.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/data/PermissionMode.java similarity index 100% rename from packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/PermissionMode.java rename to packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/data/PermissionMode.java diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/Usage.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/data/Usage.java similarity index 100% rename from packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/Usage.java rename to packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/data/Usage.java diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/behavior/Allow.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/data/behavior/Allow.java similarity index 100% rename from packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/behavior/Allow.java rename to packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/data/behavior/Allow.java diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/behavior/Behavior.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/data/behavior/Behavior.java similarity index 100% rename from packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/behavior/Behavior.java rename to packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/data/behavior/Behavior.java diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/behavior/Deny.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/data/behavior/Deny.java similarity index 100% rename from packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/behavior/Deny.java rename to packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/data/behavior/Deny.java diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/Message.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/message/Message.java similarity index 100% rename from packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/Message.java rename to packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/message/Message.java diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/MessageBase.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/message/MessageBase.java similarity index 100% rename from packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/MessageBase.java rename to packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/message/MessageBase.java diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/SDKResultMessage.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/message/SDKResultMessage.java similarity index 100% rename from packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/SDKResultMessage.java rename to packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/message/SDKResultMessage.java diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/SDKSystemMessage.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/message/SDKSystemMessage.java similarity index 100% rename from packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/SDKSystemMessage.java rename to packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/message/SDKSystemMessage.java diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/SDKUserMessage.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/message/SDKUserMessage.java similarity index 100% rename from packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/SDKUserMessage.java rename to packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/message/SDKUserMessage.java diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/APIAssistantMessage.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/APIAssistantMessage.java similarity index 100% rename from packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/APIAssistantMessage.java rename to packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/APIAssistantMessage.java diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/SDKAssistantMessage.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/SDKAssistantMessage.java similarity index 100% rename from packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/SDKAssistantMessage.java rename to packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/SDKAssistantMessage.java diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/SDKPartialAssistantMessage.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/SDKPartialAssistantMessage.java similarity index 100% rename from packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/SDKPartialAssistantMessage.java rename to packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/SDKPartialAssistantMessage.java diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/Annotation.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/Annotation.java similarity index 100% rename from packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/Annotation.java rename to packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/Annotation.java diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ContentBlock.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ContentBlock.java similarity index 100% rename from packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ContentBlock.java rename to packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ContentBlock.java diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/TextBlock.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/TextBlock.java similarity index 100% rename from packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/TextBlock.java rename to packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/TextBlock.java diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ThinkingBlock.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ThinkingBlock.java similarity index 100% rename from packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ThinkingBlock.java rename to packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ThinkingBlock.java diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ToolResultBlock.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ToolResultBlock.java similarity index 100% rename from packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ToolResultBlock.java rename to packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ToolResultBlock.java diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ToolUseBlock.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ToolUseBlock.java similarity index 100% rename from packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ToolUseBlock.java rename to packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ToolUseBlock.java diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/ContentBlockDeltaEvent.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/ContentBlockDeltaEvent.java similarity index 100% rename from packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/ContentBlockDeltaEvent.java rename to packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/ContentBlockDeltaEvent.java diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/ContentBlockStartEvent.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/ContentBlockStartEvent.java similarity index 100% rename from packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/ContentBlockStartEvent.java rename to packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/ContentBlockStartEvent.java diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/ContentBlockStopEvent.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/ContentBlockStopEvent.java similarity index 100% rename from packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/ContentBlockStopEvent.java rename to packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/ContentBlockStopEvent.java diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/MessageStartStreamEvent.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/MessageStartStreamEvent.java similarity index 100% rename from packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/MessageStartStreamEvent.java rename to packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/MessageStartStreamEvent.java diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/MessageStopStreamEvent.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/MessageStopStreamEvent.java similarity index 100% rename from packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/MessageStopStreamEvent.java rename to packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/MessageStopStreamEvent.java diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/StreamEvent.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/StreamEvent.java similarity index 100% rename from packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/StreamEvent.java rename to packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/StreamEvent.java diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlRequest.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlRequest.java similarity index 100% rename from packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlRequest.java rename to packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlRequest.java diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlResponse.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlResponse.java similarity index 100% rename from packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlResponse.java rename to packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlResponse.java diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/CLIControlInitializeRequest.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/CLIControlInitializeRequest.java similarity index 100% rename from packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/CLIControlInitializeRequest.java rename to packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/CLIControlInitializeRequest.java diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/CLIControlInitializeResponse.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/CLIControlInitializeResponse.java similarity index 100% rename from packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/CLIControlInitializeResponse.java rename to packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/CLIControlInitializeResponse.java diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/CLIControlInterruptRequest.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/CLIControlInterruptRequest.java similarity index 100% rename from packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/CLIControlInterruptRequest.java rename to packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/CLIControlInterruptRequest.java diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/CLIControlPermissionRequest.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/CLIControlPermissionRequest.java similarity index 100% rename from packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/CLIControlPermissionRequest.java rename to packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/CLIControlPermissionRequest.java diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/CLIControlPermissionResponse.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/CLIControlPermissionResponse.java similarity index 100% rename from packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/CLIControlPermissionResponse.java rename to packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/CLIControlPermissionResponse.java diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/CLIControlSetModelRequest.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/CLIControlSetModelRequest.java similarity index 100% rename from packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/CLIControlSetModelRequest.java rename to packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/CLIControlSetModelRequest.java diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/CLIControlSetModelResponse.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/CLIControlSetModelResponse.java similarity index 100% rename from packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/CLIControlSetModelResponse.java rename to packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/CLIControlSetModelResponse.java diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/CLIControlSetPermissionModeRequest.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/CLIControlSetPermissionModeRequest.java similarity index 100% rename from packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/CLIControlSetPermissionModeRequest.java rename to packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/CLIControlSetPermissionModeRequest.java diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/ControlRequestPayload.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/ControlRequestPayload.java similarity index 100% rename from packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/ControlRequestPayload.java rename to packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/ControlRequestPayload.java diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/ControlResponsePayload.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/ControlResponsePayload.java similarity index 100% rename from packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/ControlResponsePayload.java rename to packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/ControlResponsePayload.java diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/protocol.ts b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/protocol.ts similarity index 100% rename from packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/protocol.ts rename to packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/protocol.ts diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/Session.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/session/Session.java similarity index 100% rename from packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/Session.java rename to packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/session/Session.java diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/consumers/AssistantContentConsumers.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/session/event/consumers/AssistantContentConsumers.java similarity index 100% rename from packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/consumers/AssistantContentConsumers.java rename to packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/session/event/consumers/AssistantContentConsumers.java diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/consumers/AssistantContentSimpleConsumers.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/session/event/consumers/AssistantContentSimpleConsumers.java similarity index 100% rename from packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/consumers/AssistantContentSimpleConsumers.java rename to packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/session/event/consumers/AssistantContentSimpleConsumers.java diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/consumers/SessionEventConsumers.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/session/event/consumers/SessionEventConsumers.java similarity index 100% rename from packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/consumers/SessionEventConsumers.java rename to packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/session/event/consumers/SessionEventConsumers.java diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/consumers/SessionEventSimpleConsumers.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/session/event/consumers/SessionEventSimpleConsumers.java similarity index 100% rename from packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/consumers/SessionEventSimpleConsumers.java rename to packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/session/event/consumers/SessionEventSimpleConsumers.java diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/exception/SessionControlException.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/session/exception/SessionControlException.java similarity index 100% rename from packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/exception/SessionControlException.java rename to packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/session/exception/SessionControlException.java diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/exception/SessionSendPromptException.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/session/exception/SessionSendPromptException.java similarity index 100% rename from packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/exception/SessionSendPromptException.java rename to packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/session/exception/SessionSendPromptException.java diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/Transport.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/transport/Transport.java similarity index 100% rename from packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/Transport.java rename to packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/transport/Transport.java diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/TransportOptions.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/transport/TransportOptions.java similarity index 100% rename from packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/TransportOptions.java rename to packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/transport/TransportOptions.java diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/process/ProcessTransport.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/transport/process/ProcessTransport.java similarity index 100% rename from packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/process/ProcessTransport.java rename to packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/transport/process/ProcessTransport.java diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/process/TransportOptionsAdapter.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/transport/process/TransportOptionsAdapter.java similarity index 100% rename from packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/process/TransportOptionsAdapter.java rename to packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/transport/process/TransportOptionsAdapter.java diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/utils/MyConcurrentUtils.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/utils/MyConcurrentUtils.java similarity index 100% rename from packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/utils/MyConcurrentUtils.java rename to packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/utils/MyConcurrentUtils.java diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/utils/ThreadPoolConfig.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/utils/ThreadPoolConfig.java similarity index 100% rename from packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/utils/ThreadPoolConfig.java rename to packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/utils/ThreadPoolConfig.java diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/utils/Timeout.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/utils/Timeout.java similarity index 100% rename from packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/utils/Timeout.java rename to packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/utils/Timeout.java diff --git a/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/QwenCodeCliTest.java b/packages/sdk-java/qwencode/src/test/java/com/alibaba/qwen/code/cli/QwenCodeCliTest.java similarity index 100% rename from packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/QwenCodeCliTest.java rename to packages/sdk-java/qwencode/src/test/java/com/alibaba/qwen/code/cli/QwenCodeCliTest.java diff --git a/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/example/QuickStartExample.java b/packages/sdk-java/qwencode/src/test/java/com/alibaba/qwen/code/cli/example/QuickStartExample.java similarity index 100% rename from packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/example/QuickStartExample.java rename to packages/sdk-java/qwencode/src/test/java/com/alibaba/qwen/code/cli/example/QuickStartExample.java diff --git a/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/example/SessionExample.java b/packages/sdk-java/qwencode/src/test/java/com/alibaba/qwen/code/cli/example/SessionExample.java similarity index 100% rename from packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/example/SessionExample.java rename to packages/sdk-java/qwencode/src/test/java/com/alibaba/qwen/code/cli/example/SessionExample.java diff --git a/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/example/ThreadPoolConfigurationExample.java b/packages/sdk-java/qwencode/src/test/java/com/alibaba/qwen/code/cli/example/ThreadPoolConfigurationExample.java similarity index 100% rename from packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/example/ThreadPoolConfigurationExample.java rename to packages/sdk-java/qwencode/src/test/java/com/alibaba/qwen/code/cli/example/ThreadPoolConfigurationExample.java diff --git a/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/session/SessionTest.java b/packages/sdk-java/qwencode/src/test/java/com/alibaba/qwen/code/cli/session/SessionTest.java similarity index 100% rename from packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/session/SessionTest.java rename to packages/sdk-java/qwencode/src/test/java/com/alibaba/qwen/code/cli/session/SessionTest.java diff --git a/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/transport/PermissionModeTest.java b/packages/sdk-java/qwencode/src/test/java/com/alibaba/qwen/code/cli/transport/PermissionModeTest.java similarity index 100% rename from packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/transport/PermissionModeTest.java rename to packages/sdk-java/qwencode/src/test/java/com/alibaba/qwen/code/cli/transport/PermissionModeTest.java diff --git a/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/transport/process/ProcessTransportTest.java b/packages/sdk-java/qwencode/src/test/java/com/alibaba/qwen/code/cli/transport/process/ProcessTransportTest.java similarity index 100% rename from packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/transport/process/ProcessTransportTest.java rename to packages/sdk-java/qwencode/src/test/java/com/alibaba/qwen/code/cli/transport/process/ProcessTransportTest.java From ae2f77ed22b754f6115b5b977ff6f614210199ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=A3=95=E6=B3=A2?= Date: Wed, 4 Feb 2026 21:40:31 +0800 Subject: [PATCH 31/49] =?UTF-8?q?refactor(i18n):=20translate=20Agent=20as?= =?UTF-8?q?=20=E6=99=BA=E8=83=BD=E4=BD=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/cli/src/i18n/locales/zh.js | 64 ++++++++++++++--------------- 1 file changed, 32 insertions(+), 32 deletions(-) diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 8bb85cc7f..1fb300f5d 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -145,15 +145,15 @@ export default { // Commands - Agents // ============================================================================ 'Manage subagents for specialized task delegation.': - '管理用于专门任务委派的子代理', + '管理用于专门任务委派的子智能体', 'Manage existing subagents (view, edit, delete).': - '管理现有子代理(查看、编辑、删除)', - 'Create a new subagent with guided setup.': '通过引导式设置创建新的子代理', + '管理现有子智能体(查看、编辑、删除)', + 'Create a new subagent with guided setup.': '通过引导式设置创建新的子智能体', // ============================================================================ // Agents - Management Dialog // ============================================================================ - Agents: '代理', + Agents: '智能体', 'Choose Action': '选择操作', 'Edit {{name}}': '编辑 {{name}}', 'Edit Tools: {{name}}': '编辑工具: {{name}}', @@ -168,21 +168,21 @@ export default { 'Enter to select, ↑↓ to navigate, Esc to go back': 'Enter 选择,↑↓ 导航,Esc 返回', 'Invalid step: {{step}}': '无效步骤: {{step}}', - 'No subagents found.': '未找到子代理。', + 'No subagents found.': '未找到子智能体。', "Use '/agents create' to create your first subagent.": - "使用 '/agents create' 创建您的第一个子代理。", + "使用 '/agents create' 创建您的第一个子智能体。", '(built-in)': '(内置)', - '(overridden by project level agent)': '(已被项目级代理覆盖)', + '(overridden by project level agent)': '(已被项目级智能体覆盖)', 'Project Level ({{path}})': '项目级 ({{path}})', 'User Level ({{path}})': '用户级 ({{path}})', - 'Built-in Agents': '内置代理', - 'Extension Agents': '扩展代理', - 'Using: {{count}} agents': '使用中: {{count}} 个代理', - 'View Agent': '查看代理', - 'Edit Agent': '编辑代理', - 'Delete Agent': '删除代理', + 'Built-in Agents': '内置智能体', + 'Extension Agents': '扩展智能体', + 'Using: {{count}} agents': '使用中: {{count}} 个智能体', + 'View Agent': '查看智能体', + 'Edit Agent': '编辑智能体', + 'Delete Agent': '删除智能体', Back: '返回', - 'No agent selected': '未选择代理', + 'No agent selected': '未选择智能体', 'File Path: ': '文件路径: ', 'Tools: ': '工具: ', 'Color: ': '颜色: ', @@ -193,25 +193,25 @@ export default { 'Edit color': '编辑颜色', '❌ Error:': '❌ 错误:', 'Are you sure you want to delete agent "{{name}}"?': - '您确定要删除代理 "{{name}}" 吗?', + '您确定要删除智能体 "{{name}}" 吗?', // ============================================================================ // Agents - Creation Wizard // ============================================================================ 'Project Level (.qwen/agents/)': '项目级 (.qwen/agents/)', 'User Level (~/.qwen/agents/)': '用户级 (~/.qwen/agents/)', - '✅ Subagent Created Successfully!': '✅ 子代理创建成功!', + '✅ Subagent Created Successfully!': '✅ 子智能体创建成功!', 'Subagent "{{name}}" has been saved to {{level}} level.': - '子代理 "{{name}}" 已保存到 {{level}} 级别。', + '子智能体 "{{name}}" 已保存到 {{level}} 级别。', 'Name: ': '名称: ', 'Location: ': '位置: ', - '❌ Error saving subagent:': '❌ 保存子代理时出错:', + '❌ Error saving subagent:': '❌ 保存子智能体时出错:', 'Warnings:': '警告:', 'Name "{{name}}" already exists at {{level}} level - will overwrite existing subagent': - '名称 "{{name}}" 在 {{level}} 级别已存在 - 将覆盖现有子代理', + '名称 "{{name}}" 在 {{level}} 级别已存在 - 将覆盖现有子智能体', 'Name "{{name}}" exists at user level - project level will take precedence': '名称 "{{name}}" 在用户级别存在 - 项目级别将优先', 'Name "{{name}}" exists at project level - existing subagent will take precedence': - '名称 "{{name}}" 在项目级别存在 - 现有子代理将优先', + '名称 "{{name}}" 在项目级别存在 - 现有子智能体将优先', 'Description is over {{length}} characters': '描述超过 {{length}} 个字符', 'System prompt is over {{length}} characters': '系统提示超过 {{length}} 个字符', @@ -221,13 +221,13 @@ export default { 'Generate with Qwen Code (Recommended)': '使用 Qwen Code 生成(推荐)', 'Manual Creation': '手动创建', 'Describe what this subagent should do and when it should be used. (Be comprehensive for best results)': - '描述此子代理应该做什么以及何时使用它。(为了获得最佳效果,请全面描述)', + '描述此子智能体应该做什么以及何时使用它。(为了获得最佳效果,请全面描述)', 'e.g., Expert code reviewer that reviews code based on best practices...': '例如:专业的代码审查员,根据最佳实践审查代码...', - 'Generating subagent configuration...': '正在生成子代理配置...', - 'Failed to generate subagent: {{error}}': '生成子代理失败: {{error}}', - 'Step {{n}}: Describe Your Subagent': '步骤 {{n}}: 描述您的子代理', - 'Step {{n}}: Enter Subagent Name': '步骤 {{n}}: 输入子代理名称', + 'Generating subagent configuration...': '正在生成子智能体配置...', + 'Failed to generate subagent: {{error}}': '生成子智能体失败: {{error}}', + 'Step {{n}}: Describe Your Subagent': '步骤 {{n}}: 描述您的子智能体', + 'Step {{n}}: Enter Subagent Name': '步骤 {{n}}: 输入子智能体名称', 'Step {{n}}: Enter System Prompt': '步骤 {{n}}: 输入系统提示', 'Step {{n}}: Enter Description': '步骤 {{n}}: 输入描述', // Agents - Tool Selection @@ -254,22 +254,22 @@ export default { 'go back': '返回', '↑↓ to navigate, ': '↑↓ 导航,', 'Enter a clear, unique name for this subagent.': - '为此子代理输入一个清晰、唯一的名称。', + '为此子智能体输入一个清晰、唯一的名称。', 'e.g., Code Reviewer': '例如:代码审查员', 'Name cannot be empty.': '名称不能为空。', "Write the system prompt that defines this subagent's behavior. Be comprehensive for best results.": - '编写定义此子代理行为的系统提示。为了获得最佳效果,请全面描述。', + '编写定义此子智能体行为的系统提示。为了获得最佳效果,请全面描述。', 'e.g., You are an expert code reviewer...': '例如:您是一位专业的代码审查员...', 'System prompt cannot be empty.': '系统提示不能为空。', 'Describe when and how this subagent should be used.': - '描述何时以及如何使用此子代理。', + '描述何时以及如何使用此子智能体。', 'e.g., Reviews code for best practices and potential bugs.': '例如:审查代码以查找最佳实践和潜在错误。', 'Description cannot be empty.': '描述不能为空。', 'Failed to launch editor: {{error}}': '启动编辑器失败: {{error}}', 'Failed to save and edit subagent: {{error}}': - '保存并编辑子代理失败: {{error}}', + '保存并编辑子智能体失败: {{error}}', // ============================================================================ // Commands - General (continued) @@ -419,7 +419,7 @@ export default { '此扩展将排除以下核心工具:{{tools}}', 'This extension will install the following skills:': '此扩展将安装以下技能:', 'This extension will install the following subagents:': - '此扩展将安装以下子代理:', + '此扩展将安装以下子智能体:', 'Installation cancelled for "{{name}}".': '已取消安装 "{{name}}"。', '--ref and --auto-update are not applicable for marketplace extensions.': '--ref 和 --auto-update 不适用于市场扩展。', @@ -479,7 +479,7 @@ export default { 'Enabled (Workspace):': '已启用(工作区):', 'Context files:': '上下文文件:', 'Skills:': '技能:', - 'Agents:': '代理:', + 'Agents:': '智能体:', 'MCP servers:': 'MCP 服务器:', 'Link extension failed to install.': '链接扩展安装失败。', 'Extension "{{name}}" linked successfully and enabled.': @@ -1059,7 +1059,7 @@ export default { 'Code Changes:': '代码变更:', Performance: '性能', 'Wall Time:': '总耗时:', - 'Agent Active:': '代理活跃时间:', + 'Agent Active:': '智能体活跃时间:', 'API Time:': 'API 时间:', 'Tool Time:': '工具时间:', 'Session Stats': '会话统计', From 2d75d82ec8de8d0b2a6a2e6343e9775b566cdeff Mon Sep 17 00:00:00 2001 From: Mingholy Date: Wed, 4 Feb 2026 23:14:26 +0800 Subject: [PATCH 32/49] feat(sdk): add FORK_MODE support for Electron integration - Rename USE_FORK_FOR_ELECTRON to FORK_MODE - Add fork mode in ProcessTransport for Electron IPC support - Add comprehensive unit tests for fork mode Co-authored-by: Qwen-Coder --- .../src/transport/ProcessTransport.ts | 69 ++++- .../test/unit/ProcessTransport.test.ts | 256 ++++++++++++++++++ 2 files changed, 314 insertions(+), 11 deletions(-) diff --git a/packages/sdk-typescript/src/transport/ProcessTransport.ts b/packages/sdk-typescript/src/transport/ProcessTransport.ts index ff4518833..17f142d0e 100644 --- a/packages/sdk-typescript/src/transport/ProcessTransport.ts +++ b/packages/sdk-typescript/src/transport/ProcessTransport.ts @@ -1,4 +1,4 @@ -import { spawn, type ChildProcess } from 'node:child_process'; +import { spawn, fork, type ChildProcess } from 'node:child_process'; import * as readline from 'node:readline'; import type { Writable, Readable } from 'node:stream'; import type { TransportOptions } from '../types/types.js'; @@ -52,20 +52,67 @@ export class ProcessTransport implements Transport { const stderrMode = this.options.debug || this.options.stderr ? 'pipe' : 'ignore'; - logger.debug( - `Spawning CLI (${spawnInfo.type}): ${spawnInfo.command} ${[...spawnInfo.args, ...cliArgs].join(' ')}`, - ); + // Check if we should use fork for Electron integration + const useFork = env.FORK_MODE === '1'; + console.log('useFork', useFork); - this.childProcess = spawn( - spawnInfo.command, - [...spawnInfo.args, ...cliArgs], - { + if (useFork) { + // Detect Electron environment + const isElectron = + typeof process !== 'undefined' && + process.versions && + !!process.versions.electron; + + // In Electron, process.execPath points to Electron, not Node.js + // When spawnInfo uses process.execPath to run a JS file, we need to handle it specially + const isUsingExecPathForJs = + spawnInfo.args.length > 0 && + (spawnInfo.args[0]?.endsWith('.js') || + spawnInfo.args[0]?.endsWith('.mjs') || + spawnInfo.args[0]?.endsWith('.cjs')); + + let forkModulePath: string; + let forkArgs: string[]; + + if (isElectron && isUsingExecPathForJs) { + // In Electron with JS file: use the JS file as module path, rest as args + forkModulePath = spawnInfo.args[0] ?? ''; + forkArgs = [...spawnInfo.args.slice(1), ...cliArgs]; + } else { + // Normal case: use command as module path + forkModulePath = spawnInfo.command; + forkArgs = [...spawnInfo.args, ...cliArgs]; + } + + logger.debug( + `Forking CLI (${spawnInfo.type}): ${forkModulePath} ${forkArgs.join(' ')}`, + ); + + this.childProcess = fork(forkModulePath, forkArgs, { cwd, env, - stdio: ['pipe', 'pipe', stderrMode], + stdio: + stderrMode === 'pipe' + ? ['pipe', 'pipe', 'pipe', 'ipc'] + : ['pipe', 'pipe', 'ignore', 'ipc'], signal: this.abortController.signal, - }, - ); + }); + } else { + logger.debug( + `Spawning CLI (${spawnInfo.type}): ${spawnInfo.command} ${[...spawnInfo.args, ...cliArgs].join(' ')}`, + ); + + this.childProcess = spawn( + spawnInfo.command, + [...spawnInfo.args, ...cliArgs], + { + cwd, + env, + stdio: ['pipe', 'pipe', stderrMode], + signal: this.abortController.signal, + }, + ); + } this.childStdin = this.childProcess.stdin; this.childStdout = this.childProcess.stdout; diff --git a/packages/sdk-typescript/test/unit/ProcessTransport.test.ts b/packages/sdk-typescript/test/unit/ProcessTransport.test.ts index 87bf6bc2a..872452716 100644 --- a/packages/sdk-typescript/test/unit/ProcessTransport.test.ts +++ b/packages/sdk-typescript/test/unit/ProcessTransport.test.ts @@ -20,6 +20,7 @@ vi.mock('../../src/utils/cliPath.js'); vi.mock('../../src/utils/jsonLines.js'); const mockSpawn = vi.mocked(childProcess.spawn); +const mockFork = vi.mocked(childProcess.fork); const mockPrepareSpawnInfo = vi.mocked(cliPath.prepareSpawnInfo); const mockParseJsonLinesStream = vi.mocked(jsonLines.parseJsonLinesStream); @@ -75,6 +76,10 @@ describe('ProcessTransport', () => { beforeEach(() => { vi.clearAllMocks(); + // Clean up environment variables for FORK_MODE tests + delete process.env.FORK_MODE; + delete (process.versions as { electron?: string }).electron; + const mockWriteFn = vi.fn((chunk, encoding, callback) => { if (typeof callback === 'function') callback(); return true; @@ -1383,4 +1388,255 @@ describe('ProcessTransport', () => { expect(transport.getOutputStream()).toBeUndefined(); }); }); + + describe('Fork Mode', () => { + it('should use fork when FORK_MODE=1', () => { + process.env.FORK_MODE = '1'; + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockFork.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + new ProcessTransport(options); + + expect(mockFork).toHaveBeenCalledTimes(1); + expect(mockSpawn).not.toHaveBeenCalled(); + }); + + it('should use spawn when FORK_MODE is not set', () => { + // process.env.FORK_MODE is not set + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + new ProcessTransport(options); + + expect(mockSpawn).toHaveBeenCalledTimes(1); + expect(mockFork).not.toHaveBeenCalled(); + }); + + it('should pass correct modulePath to fork', () => { + process.env.FORK_MODE = '1'; + mockPrepareSpawnInfo.mockReturnValue({ + command: 'node', + args: ['/path/to/cli.js'], + type: 'node', + originalInput: 'node /path/to/cli.js', + }); + mockFork.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + new ProcessTransport(options); + + // In non-Electron environment, command is used as modulePath + expect(mockFork).toHaveBeenCalledWith( + 'node', // modulePath is the command in non-Electron environment + expect.arrayContaining(['/path/to/cli.js']), + expect.any(Object), + ); + }); + + it('should configure stdio with ipc channel for fork', () => { + process.env.FORK_MODE = '1'; + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockFork.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + new ProcessTransport(options); + + expect(mockFork).toHaveBeenCalledWith( + expect.any(String), + expect.any(Array), + expect.objectContaining({ + stdio: ['pipe', 'pipe', 'ignore', 'ipc'], // 4th element is ipc + }), + ); + }); + + it('should configure stdio with pipe for stderr when debug mode is on', () => { + process.env.FORK_MODE = '1'; + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockFork.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + debug: true, + }; + + new ProcessTransport(options); + + expect(mockFork).toHaveBeenCalledWith( + expect.any(String), + expect.any(Array), + expect.objectContaining({ + stdio: ['pipe', 'pipe', 'pipe', 'ipc'], // stderr is also pipe + }), + ); + }); + + it('should handle Electron environment with JS file execution', () => { + process.env.FORK_MODE = '1'; + (process.versions as { electron?: string }).electron = '28.0.0'; + + mockPrepareSpawnInfo.mockReturnValue({ + command: '/path/to/Electron.app/Contents/MacOS/Electron', + args: ['/path/to/cli.js', '--some-arg'], + type: 'node', + originalInput: 'electron /path/to/cli.js', + }); + mockFork.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + new ProcessTransport(options); + + // In Electron environment, should extract cli.js as modulePath + expect(mockFork).toHaveBeenCalledWith( + '/path/to/cli.js', + expect.arrayContaining(['--some-arg']), + expect.any(Object), + ); + }); + + it('should handle normal JS execution in non-Electron environment', () => { + process.env.FORK_MODE = '1'; + // process.versions.electron is not set + + mockPrepareSpawnInfo.mockReturnValue({ + command: 'node', + args: ['/path/to/cli.js', '--some-arg'], + type: 'node', + originalInput: 'node /path/to/cli.js', + }); + mockFork.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + new ProcessTransport(options); + + // In normal Node.js, command is used as modulePath, not cli.js + expect(mockFork).toHaveBeenCalledWith( + 'node', + expect.arrayContaining(['/path/to/cli.js', '--some-arg']), + expect.any(Object), + ); + }); + + it('should pass env to fork', () => { + process.env.FORK_MODE = '1'; + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockFork.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + env: { CUSTOM_VAR: 'value' }, + }; + + new ProcessTransport(options); + + expect(mockFork).toHaveBeenCalledWith( + expect.any(String), + expect.any(Array), + expect.objectContaining({ + env: expect.objectContaining({ + CUSTOM_VAR: 'value', + FORK_MODE: '1', + }), + }), + ); + }); + + it('should pass cwd to fork', () => { + process.env.FORK_MODE = '1'; + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockFork.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + cwd: '/custom/workdir', + }; + + new ProcessTransport(options); + + expect(mockFork).toHaveBeenCalledWith( + expect.any(String), + expect.any(Array), + expect.objectContaining({ + cwd: '/custom/workdir', + }), + ); + }); + + it('should pass abort signal to fork', () => { + process.env.FORK_MODE = '1'; + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockFork.mockReturnValue(mockChildProcess); + + const abortController = new AbortController(); + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + abortController, + }; + + new ProcessTransport(options); + + expect(mockFork).toHaveBeenCalledWith( + expect.any(String), + expect.any(Array), + expect.objectContaining({ + signal: abortController.signal, + }), + ); + }); + }); }); From 50059d6f323458cf9ac87cac78f97ea46f068469 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Thu, 5 Feb 2026 11:30:45 +0800 Subject: [PATCH 33/49] chore(release): Bump version to 0.10.0 across all packages Co-authored-by: Qwen-Coder --- package-lock.json | 14 +++++++------- package.json | 4 ++-- packages/cli/package.json | 4 ++-- packages/core/package.json | 2 +- packages/test-utils/package.json | 2 +- packages/vscode-ide-companion/package.json | 2 +- packages/webui/package.json | 2 +- 7 files changed, 15 insertions(+), 15 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6b42f369f..ff5a902d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@qwen-code/qwen-code", - "version": "0.9.0", + "version": "0.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@qwen-code/qwen-code", - "version": "0.9.0", + "version": "0.10.0", "workspaces": [ "packages/*" ], @@ -18655,7 +18655,7 @@ }, "packages/cli": { "name": "@qwen-code/qwen-code", - "version": "0.9.0", + "version": "0.10.0", "dependencies": { "@google/genai": "1.30.0", "@iarna/toml": "^2.2.5", @@ -19274,7 +19274,7 @@ }, "packages/core": { "name": "@qwen-code/qwen-code-core", - "version": "0.9.0", + "version": "0.10.0", "hasInstallScript": true, "dependencies": { "@anthropic-ai/sdk": "^0.36.1", @@ -22754,7 +22754,7 @@ }, "packages/test-utils": { "name": "@qwen-code/qwen-code-test-utils", - "version": "0.9.0", + "version": "0.10.0", "dev": true, "license": "Apache-2.0", "devDependencies": { @@ -22766,7 +22766,7 @@ }, "packages/vscode-ide-companion": { "name": "qwen-code-vscode-ide-companion", - "version": "0.9.0", + "version": "0.10.0", "license": "LICENSE", "dependencies": { "@modelcontextprotocol/sdk": "^1.25.1", @@ -23013,7 +23013,7 @@ }, "packages/webui": { "name": "@qwen-code/webui", - "version": "0.1.0-beta.4", + "version": "0.10.0", "license": "MIT", "dependencies": { "markdown-it": "^14.1.0" diff --git a/package.json b/package.json index 43d142ebd..374dd32c6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code", - "version": "0.9.0", + "version": "0.10.0", "engines": { "node": ">=20.0.0" }, @@ -13,7 +13,7 @@ "url": "git+https://github.com/QwenLM/qwen-code.git" }, "config": { - "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.9.0" + "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.10.0" }, "scripts": { "start": "cross-env node scripts/start.js", diff --git a/packages/cli/package.json b/packages/cli/package.json index 14e9a460e..a6ef9366a 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code", - "version": "0.9.0", + "version": "0.10.0", "description": "Qwen Code", "repository": { "type": "git", @@ -33,7 +33,7 @@ "dist" ], "config": { - "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.9.0" + "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.10.0" }, "dependencies": { "@google/genai": "1.30.0", diff --git a/packages/core/package.json b/packages/core/package.json index 659c22302..3f55393f3 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code-core", - "version": "0.9.0", + "version": "0.10.0", "description": "Qwen Code Core", "repository": { "type": "git", diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index 2ab9807fb..b3425fe61 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code-test-utils", - "version": "0.9.0", + "version": "0.10.0", "private": true, "main": "src/index.ts", "license": "Apache-2.0", diff --git a/packages/vscode-ide-companion/package.json b/packages/vscode-ide-companion/package.json index a8339c466..f93a60027 100644 --- a/packages/vscode-ide-companion/package.json +++ b/packages/vscode-ide-companion/package.json @@ -2,7 +2,7 @@ "name": "qwen-code-vscode-ide-companion", "displayName": "Qwen Code Companion", "description": "Enable Qwen Code with direct access to your VS Code workspace.", - "version": "0.9.0", + "version": "0.10.0", "publisher": "qwenlm", "icon": "assets/icon.png", "repository": { diff --git a/packages/webui/package.json b/packages/webui/package.json index 4b6221a1a..246d9a3e7 100644 --- a/packages/webui/package.json +++ b/packages/webui/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/webui", - "version": "0.1.0-beta.4", + "version": "0.10.0", "description": "Shared UI components for Qwen Code packages", "type": "module", "main": "./dist/index.cjs", From 7cf97e5d3606dfac157e53f5d2449e08aa664464 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Wed, 4 Feb 2026 18:32:39 +0800 Subject: [PATCH 34/49] fix: clarify is_background parameter is required in docs and examples Co-authored-by: Qwen-Coder --- .../core/__snapshots__/prompts.test.ts.snap | 100 +++++++++++------- packages/core/src/core/prompts.test.ts | 4 +- packages/core/src/core/prompts.ts | 23 ++-- packages/core/src/tools/shell.ts | 2 +- 4 files changed, 79 insertions(+), 50 deletions(-) diff --git a/packages/core/src/core/__snapshots__/prompts.test.ts.snap b/packages/core/src/core/__snapshots__/prompts.test.ts.snap index 0c0b6c6ad..1072c956a 100644 --- a/packages/core/src/core/__snapshots__/prompts.test.ts.snap +++ b/packages/core/src/core/__snapshots__/prompts.test.ts.snap @@ -149,7 +149,7 @@ model: true user: start the server implemented in server.js -model: [tool_call: run_shell_command for 'node server.js &' because it must run in the background] +model: [tool_call: run_shell_command for 'node server.js &' with is_background: true because it must run in the background] @@ -173,7 +173,7 @@ Here's the plan: [tool_call: edit for path 'src/auth.py' replacing old content with new content] Refactoring complete. Running verification... -[tool_call: run_shell_command for 'ruff check src/auth.py && pytest'] +[tool_call: run_shell_command for 'ruff check src/auth.py && pytest' with is_background: false] (After verification passes) All checks passed. This is a stable checkpoint. Would you like me to write a commit message and commit these changes? @@ -194,7 +194,7 @@ Now I'll look for existing or related test files to understand current testing c (After reviewing existing tests and the file content) [tool_call: write_file for path '/path/to/someFile.test.ts'] I've written the tests. Now I'll run the project's test command to verify them. -[tool_call: run_shell_command for 'npm run test'] +[tool_call: run_shell_command for 'npm run test' with is_background: false] (After verification passes) All checks passed. This is a stable checkpoint. @@ -383,7 +383,7 @@ model: true user: start the server implemented in server.js -model: [tool_call: run_shell_command for 'node server.js &' because it must run in the background] +model: [tool_call: run_shell_command for 'node server.js &' with is_background: true because it must run in the background] @@ -407,7 +407,7 @@ Here's the plan: [tool_call: edit for path 'src/auth.py' replacing old content with new content] Refactoring complete. Running verification... -[tool_call: run_shell_command for 'ruff check src/auth.py && pytest'] +[tool_call: run_shell_command for 'ruff check src/auth.py && pytest' with is_background: false] (After verification passes) All checks passed. This is a stable checkpoint. Would you like me to write a commit message and commit these changes? @@ -428,7 +428,7 @@ Now I'll look for existing or related test files to understand current testing c (After reviewing existing tests and the file content) [tool_call: write_file for path '/path/to/someFile.test.ts'] I've written the tests. Now I'll run the project's test command to verify them. -[tool_call: run_shell_command for 'npm run test'] +[tool_call: run_shell_command for 'npm run test' with is_background: false] (After verification passes) All checks passed. This is a stable checkpoint. @@ -597,7 +597,7 @@ model: true user: start the server implemented in server.js -model: [tool_call: run_shell_command for 'node server.js &' because it must run in the background] +model: [tool_call: run_shell_command for 'node server.js &' with is_background: true because it must run in the background] @@ -621,7 +621,7 @@ Here's the plan: [tool_call: edit for path 'src/auth.py' replacing old content with new content] Refactoring complete. Running verification... -[tool_call: run_shell_command for 'ruff check src/auth.py && pytest'] +[tool_call: run_shell_command for 'ruff check src/auth.py && pytest' with is_background: false] (After verification passes) All checks passed. This is a stable checkpoint. Would you like me to write a commit message and commit these changes? @@ -642,7 +642,7 @@ Now I'll look for existing or related test files to understand current testing c (After reviewing existing tests and the file content) [tool_call: write_file for path '/path/to/someFile.test.ts'] I've written the tests. Now I'll run the project's test command to verify them. -[tool_call: run_shell_command for 'npm run test'] +[tool_call: run_shell_command for 'npm run test' with is_background: false] (After verification passes) All checks passed. This is a stable checkpoint. @@ -811,7 +811,7 @@ model: true user: start the server implemented in server.js -model: [tool_call: run_shell_command for 'node server.js &' because it must run in the background] +model: [tool_call: run_shell_command for 'node server.js &' with is_background: true because it must run in the background] @@ -835,7 +835,7 @@ Here's the plan: [tool_call: edit for path 'src/auth.py' replacing old content with new content] Refactoring complete. Running verification... -[tool_call: run_shell_command for 'ruff check src/auth.py && pytest'] +[tool_call: run_shell_command for 'ruff check src/auth.py && pytest' with is_background: false] (After verification passes) All checks passed. This is a stable checkpoint. Would you like me to write a commit message and commit these changes? @@ -856,7 +856,7 @@ Now I'll look for existing or related test files to understand current testing c (After reviewing existing tests and the file content) [tool_call: write_file for path '/path/to/someFile.test.ts'] I've written the tests. Now I'll run the project's test command to verify them. -[tool_call: run_shell_command for 'npm run test'] +[tool_call: run_shell_command for 'npm run test' with is_background: false] (After verification passes) All checks passed. This is a stable checkpoint. @@ -1025,7 +1025,7 @@ model: true user: start the server implemented in server.js -model: [tool_call: run_shell_command for 'node server.js &' because it must run in the background] +model: [tool_call: run_shell_command for 'node server.js &' with is_background: true because it must run in the background] @@ -1049,7 +1049,7 @@ Here's the plan: [tool_call: edit for path 'src/auth.py' replacing old content with new content] Refactoring complete. Running verification... -[tool_call: run_shell_command for 'ruff check src/auth.py && pytest'] +[tool_call: run_shell_command for 'ruff check src/auth.py && pytest' with is_background: false] (After verification passes) All checks passed. This is a stable checkpoint. Would you like me to write a commit message and commit these changes? @@ -1070,7 +1070,7 @@ Now I'll look for existing or related test files to understand current testing c (After reviewing existing tests and the file content) [tool_call: write_file for path '/path/to/someFile.test.ts'] I've written the tests. Now I'll run the project's test command to verify them. -[tool_call: run_shell_command for 'npm run test'] +[tool_call: run_shell_command for 'npm run test' with is_background: false] (After verification passes) All checks passed. This is a stable checkpoint. @@ -1239,7 +1239,7 @@ model: true user: start the server implemented in server.js -model: [tool_call: run_shell_command for 'node server.js &' because it must run in the background] +model: [tool_call: run_shell_command for 'node server.js &' with is_background: true because it must run in the background] @@ -1263,7 +1263,7 @@ Here's the plan: [tool_call: edit for path 'src/auth.py' replacing old content with new content] Refactoring complete. Running verification... -[tool_call: run_shell_command for 'ruff check src/auth.py && pytest'] +[tool_call: run_shell_command for 'ruff check src/auth.py && pytest' with is_background: false] (After verification passes) All checks passed. This is a stable checkpoint. Would you like me to write a commit message and commit these changes? @@ -1284,7 +1284,7 @@ Now I'll look for existing or related test files to understand current testing c (After reviewing existing tests and the file content) [tool_call: write_file for path '/path/to/someFile.test.ts'] I've written the tests. Now I'll run the project's test command to verify them. -[tool_call: run_shell_command for 'npm run test'] +[tool_call: run_shell_command for 'npm run test' with is_background: false] (After verification passes) All checks passed. This is a stable checkpoint. @@ -1453,7 +1453,7 @@ model: true user: start the server implemented in server.js -model: [tool_call: run_shell_command for 'node server.js &' because it must run in the background] +model: [tool_call: run_shell_command for 'node server.js &' with is_background: true because it must run in the background] @@ -1477,7 +1477,7 @@ Here's the plan: [tool_call: edit for path 'src/auth.py' replacing old content with new content] Refactoring complete. Running verification... -[tool_call: run_shell_command for 'ruff check src/auth.py && pytest'] +[tool_call: run_shell_command for 'ruff check src/auth.py && pytest' with is_background: false] (After verification passes) All checks passed. This is a stable checkpoint. Would you like me to write a commit message and commit these changes? @@ -1498,7 +1498,7 @@ Now I'll look for existing or related test files to understand current testing c (After reviewing existing tests and the file content) [tool_call: write_file for path '/path/to/someFile.test.ts'] I've written the tests. Now I'll run the project's test command to verify them. -[tool_call: run_shell_command for 'npm run test'] +[tool_call: run_shell_command for 'npm run test' with is_background: false] (After verification passes) All checks passed. This is a stable checkpoint. @@ -1667,7 +1667,7 @@ model: true user: start the server implemented in server.js -model: [tool_call: run_shell_command for 'node server.js &' because it must run in the background] +model: [tool_call: run_shell_command for 'node server.js &' with is_background: true because it must run in the background] @@ -1691,7 +1691,7 @@ Here's the plan: [tool_call: edit for path 'src/auth.py' replacing old content with new content] Refactoring complete. Running verification... -[tool_call: run_shell_command for 'ruff check src/auth.py && pytest'] +[tool_call: run_shell_command for 'ruff check src/auth.py && pytest' with is_background: false] (After verification passes) All checks passed. This is a stable checkpoint. Would you like me to write a commit message and commit these changes? @@ -1712,7 +1712,7 @@ Now I'll look for existing or related test files to understand current testing c (After reviewing existing tests and the file content) [tool_call: write_file for path '/path/to/someFile.test.ts'] I've written the tests. Now I'll run the project's test command to verify them. -[tool_call: run_shell_command for 'npm run test'] +[tool_call: run_shell_command for 'npm run test' with is_background: false] (After verification passes) All checks passed. This is a stable checkpoint. @@ -1881,7 +1881,7 @@ model: true user: start the server implemented in server.js -model: [tool_call: run_shell_command for 'node server.js &' because it must run in the background] +model: [tool_call: run_shell_command for 'node server.js &' with is_background: true because it must run in the background] @@ -1905,7 +1905,7 @@ Here's the plan: [tool_call: edit for path 'src/auth.py' replacing old content with new content] Refactoring complete. Running verification... -[tool_call: run_shell_command for 'ruff check src/auth.py && pytest'] +[tool_call: run_shell_command for 'ruff check src/auth.py && pytest' with is_background: false] (After verification passes) All checks passed. This is a stable checkpoint. Would you like me to write a commit message and commit these changes? @@ -1926,7 +1926,7 @@ Now I'll look for existing or related test files to understand current testing c (After reviewing existing tests and the file content) [tool_call: write_file for path '/path/to/someFile.test.ts'] I've written the tests. Now I'll run the project's test command to verify them. -[tool_call: run_shell_command for 'npm run test'] +[tool_call: run_shell_command for 'npm run test' with is_background: false] (After verification passes) All checks passed. This is a stable checkpoint. @@ -2097,7 +2097,7 @@ model: true user: start the server implemented in server.js model: -{"name": "run_shell_command", "arguments": {"command": "node server.js &"}} +{"name": "run_shell_command", "arguments": {"command": "node server.js &", "is_background": true}} @@ -2131,7 +2131,7 @@ Here's the plan: Refactoring complete. Running verification... -{"name": "run_shell_command", "arguments": {"command": "ruff check src/auth.py && pytest"}} +{"name": "run_shell_command", "arguments": {"command": "ruff check src/auth.py && pytest", "is_background": false}} (After verification passes) All checks passed. This is a stable checkpoint. @@ -2160,7 +2160,7 @@ Now I'll look for existing or related test files to understand current testing c I've written the tests. Now I'll run the project's test command to verify them. -{"name": "run_shell_command", "arguments": {"command": "npm run test"}} +{"name": "run_shell_command", "arguments": {"command": "npm run test", "is_background": false}} (After verification passes) All checks passed. This is a stable checkpoint. @@ -2332,12 +2332,15 @@ model: true user: start the server implemented in server.js -model: +model: node server.js & + +true + @@ -2404,6 +2407,9 @@ Refactoring complete. Running verification... ruff check src/auth.py && pytest + +false + (After verification passes) @@ -2449,6 +2455,9 @@ I've written the tests. Now I'll run the project's test command to verify them. npm run test + +false + (After verification passes) @@ -2631,7 +2640,7 @@ model: true user: start the server implemented in server.js model: -{"name": "run_shell_command", "arguments": {"command": "node server.js &"}} +{"name": "run_shell_command", "arguments": {"command": "node server.js &", "is_background": true}} @@ -2665,7 +2674,7 @@ Here's the plan: Refactoring complete. Running verification... -{"name": "run_shell_command", "arguments": {"command": "ruff check src/auth.py && pytest"}} +{"name": "run_shell_command", "arguments": {"command": "ruff check src/auth.py && pytest", "is_background": false}} (After verification passes) All checks passed. This is a stable checkpoint. @@ -2694,7 +2703,7 @@ Now I'll look for existing or related test files to understand current testing c I've written the tests. Now I'll run the project's test command to verify them. -{"name": "run_shell_command", "arguments": {"command": "npm run test"}} +{"name": "run_shell_command", "arguments": {"command": "npm run test", "is_background": false}} (After verification passes) All checks passed. This is a stable checkpoint. @@ -2866,12 +2875,15 @@ model: true user: start the server implemented in server.js -model: +model: node server.js & + +true + @@ -2938,6 +2950,9 @@ Refactoring complete. Running verification... ruff check src/auth.py && pytest + +false + (After verification passes) @@ -2983,6 +2998,9 @@ I've written the tests. Now I'll run the project's test command to verify them. npm run test + +false + (After verification passes) @@ -3159,7 +3177,7 @@ model: true user: start the server implemented in server.js -model: [tool_call: run_shell_command for 'node server.js &' because it must run in the background] +model: [tool_call: run_shell_command for 'node server.js &' with is_background: true because it must run in the background] @@ -3183,7 +3201,7 @@ Here's the plan: [tool_call: edit for path 'src/auth.py' replacing old content with new content] Refactoring complete. Running verification... -[tool_call: run_shell_command for 'ruff check src/auth.py && pytest'] +[tool_call: run_shell_command for 'ruff check src/auth.py && pytest' with is_background: false] (After verification passes) All checks passed. This is a stable checkpoint. Would you like me to write a commit message and commit these changes? @@ -3204,7 +3222,7 @@ Now I'll look for existing or related test files to understand current testing c (After reviewing existing tests and the file content) [tool_call: write_file for path '/path/to/someFile.test.ts'] I've written the tests. Now I'll run the project's test command to verify them. -[tool_call: run_shell_command for 'npm run test'] +[tool_call: run_shell_command for 'npm run test' with is_background: false] (After verification passes) All checks passed. This is a stable checkpoint. @@ -3373,7 +3391,7 @@ model: true user: start the server implemented in server.js -model: [tool_call: run_shell_command for 'node server.js &' because it must run in the background] +model: [tool_call: run_shell_command for 'node server.js &' with is_background: true because it must run in the background] @@ -3397,7 +3415,7 @@ Here's the plan: [tool_call: edit for path 'src/auth.py' replacing old content with new content] Refactoring complete. Running verification... -[tool_call: run_shell_command for 'ruff check src/auth.py && pytest'] +[tool_call: run_shell_command for 'ruff check src/auth.py && pytest' with is_background: false] (After verification passes) All checks passed. This is a stable checkpoint. Would you like me to write a commit message and commit these changes? @@ -3418,7 +3436,7 @@ Now I'll look for existing or related test files to understand current testing c (After reviewing existing tests and the file content) [tool_call: write_file for path '/path/to/someFile.test.ts'] I've written the tests. Now I'll run the project's test command to verify them. -[tool_call: run_shell_command for 'npm run test'] +[tool_call: run_shell_command for 'npm run test' with is_background: false] (After verification passes) All checks passed. This is a stable checkpoint. diff --git a/packages/core/src/core/prompts.test.ts b/packages/core/src/core/prompts.test.ts index a232b50f7..7a9799433 100644 --- a/packages/core/src/core/prompts.test.ts +++ b/packages/core/src/core/prompts.test.ts @@ -302,7 +302,9 @@ describe('Model-specific tool call formats', () => { // Should contain JSON-style tool calls expect(prompt).toContain(''); expect(prompt).toContain('{"name": "run_shell_command"'); - expect(prompt).toContain('"arguments": {"command": "node server.js &"}'); + expect(prompt).toContain( + '"arguments": {"command": "node server.js &", "is_background": true}', + ); expect(prompt).toContain(''); // Should NOT contain bracket-style tool calls diff --git a/packages/core/src/core/prompts.ts b/packages/core/src/core/prompts.ts index 8d3ff4683..20e83cd49 100644 --- a/packages/core/src/core/prompts.ts +++ b/packages/core/src/core/prompts.ts @@ -447,7 +447,7 @@ model: true user: start the server implemented in server.js -model: [tool_call: ${ToolNames.SHELL} for 'node server.js &' because it must run in the background] +model: [tool_call: ${ToolNames.SHELL} for 'node server.js &' with is_background: true because it must run in the background] @@ -471,7 +471,7 @@ Here's the plan: [tool_call: ${ToolNames.EDIT} for path 'src/auth.py' replacing old content with new content] Refactoring complete. Running verification... -[tool_call: ${ToolNames.SHELL} for 'ruff check src/auth.py && pytest'] +[tool_call: ${ToolNames.SHELL} for 'ruff check src/auth.py && pytest' with is_background: false] (After verification passes) All checks passed. This is a stable checkpoint. Would you like me to write a commit message and commit these changes? @@ -492,7 +492,7 @@ Now I'll look for existing or related test files to understand current testing c (After reviewing existing tests and the file content) [tool_call: ${ToolNames.WRITE_FILE} for path '/path/to/someFile.test.ts'] I've written the tests. Now I'll run the project's test command to verify them. -[tool_call: ${ToolNames.SHELL} for 'npm run test'] +[tool_call: ${ToolNames.SHELL} for 'npm run test' with is_background: false] (After verification passes) All checks passed. This is a stable checkpoint. @@ -523,12 +523,15 @@ model: true user: start the server implemented in server.js -model: +model: node server.js & + +true + @@ -595,6 +598,9 @@ Refactoring complete. Running verification... ruff check src/auth.py && pytest + +false + (After verification passes) @@ -640,6 +646,9 @@ I've written the tests. Now I'll run the project's test command to verify them. npm run test + +false + (After verification passes) @@ -679,7 +688,7 @@ model: true user: start the server implemented in server.js model: -{"name": "${ToolNames.SHELL}", "arguments": {"command": "node server.js &"}} +{"name": "${ToolNames.SHELL}", "arguments": {"command": "node server.js &", "is_background": true}} @@ -713,7 +722,7 @@ Here's the plan: Refactoring complete. Running verification... -{"name": "${ToolNames.SHELL}", "arguments": {"command": "ruff check src/auth.py && pytest"}} +{"name": "${ToolNames.SHELL}", "arguments": {"command": "ruff check src/auth.py && pytest", "is_background": false}} (After verification passes) All checks passed. This is a stable checkpoint. @@ -742,7 +751,7 @@ Now I'll look for existing or related test files to understand current testing c I've written the tests. Now I'll run the project's test command to verify them. -{"name": "${ToolNames.SHELL}", "arguments": {"command": "npm run test"}} +{"name": "${ToolNames.SHELL}", "arguments": {"command": "npm run test", "is_background": false}} (After verification passes) All checks passed. This is a stable checkpoint. diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index a02412626..ca898c4c4 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -543,7 +543,7 @@ export class ShellTool extends BaseDeclarativeTool< is_background: { type: 'boolean', description: - 'Whether to run the command in background. Default is false. Set to true for long-running processes like development servers, watchers, or daemons that should continue running without blocking further commands.', + 'Whether to run the command in background. This parameter is required to ensure explicit decision-making. Set to true for long-running processes like development servers, watchers, or daemons that should continue running without blocking further commands. Set to false for one-time commands that should complete before proceeding.', }, timeout: { type: 'number', From 9abb958a234ef8f9a543257ea7e2b84fe797a278 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Thu, 5 Feb 2026 14:41:04 +0800 Subject: [PATCH 35/49] fix(docker): use scripts/build.js and update workflow for manual builds Co-authored-by: Qwen-Coder --- .github/workflows/build-and-publish-image.yml | 60 ++++++++++++++++--- Dockerfile | 3 +- 2 files changed, 53 insertions(+), 10 deletions(-) diff --git a/.github/workflows/build-and-publish-image.yml b/.github/workflows/build-and-publish-image.yml index ab8b85fd5..0051ccebf 100644 --- a/.github/workflows/build-and-publish-image.yml +++ b/.github/workflows/build-and-publish-image.yml @@ -6,8 +6,12 @@ on: - 'v*' workflow_dispatch: inputs: + version: + description: 'Docker image version/tag (e.g., 0.9.1, 0.9.2-rc.1)' + type: 'string' + required: false publish: - description: 'Publish to GHCR (only works on main branch)' + description: 'Publish to GHCR' type: 'boolean' default: false @@ -25,6 +29,42 @@ jobs: steps: - name: 'Checkout repository' uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 + with: + ref: '${{ github.ref }}' + + - name: 'Process version' + id: 'version' + run: | + INPUT_VERSION="${{ github.event.inputs.version }}" + + # For tag pushes, extract version from the tag + if [[ -z "$INPUT_VERSION" && "${{ github.ref_type }}" == "tag" ]]; then + INPUT_VERSION="${{ github.ref_name }}" + fi + + # Strip 'v' prefix if present + CLEAN_VERSION="${INPUT_VERSION#v}" + + # Extract major.minor for floating tag (e.g., 1.0.0 -> 1.0) + MAJOR_MINOR=$(echo "$CLEAN_VERSION" | grep -oE '^[0-9]+\.[0-9]+' || true) + + echo "raw=${INPUT_VERSION}" >> "$GITHUB_OUTPUT" + echo "clean=${CLEAN_VERSION}" >> "$GITHUB_OUTPUT" + echo "major_minor=${MAJOR_MINOR}" >> "$GITHUB_OUTPUT" + echo "Input version: ${INPUT_VERSION}" + echo "Clean version: ${CLEAN_VERSION}" + echo "Major.minor: ${MAJOR_MINOR}" + + - name: 'Debug inputs' + if: |- + ${{ runner.debug == '1' }} + run: | + echo "Event name: ${{ github.event_name }}" + echo "Version input (raw): ${{ steps.version.outputs.raw }}" + echo "Version (clean): ${{ steps.version.outputs.clean }}" + echo "Major.minor: ${{ steps.version.outputs.major_minor }}" + echo "Publish input: ${{ github.event.inputs.publish }}" + echo "GitHub ref: ${{ github.ref }}" - name: 'Set up QEMU' uses: 'docker/setup-qemu-action@v3' # ratchet:exclude @@ -38,15 +78,17 @@ jobs: with: images: '${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}' tags: | - type=ref,event=branch - type=ref,event=pr - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=sha,prefix=sha-,format=short + type=raw,value=${{ steps.version.outputs.clean }},enable=${{ steps.version.outputs.clean != '' }} + type=raw,value=${{ steps.version.outputs.major_minor }},enable=${{ steps.version.outputs.major_minor != '' }} + type=ref,event=branch,enable=${{ steps.version.outputs.clean == '' }} + type=ref,event=pr,enable=${{ steps.version.outputs.clean == '' }} + type=semver,pattern={{version}},enable=${{ steps.version.outputs.clean == '' }} + type=semver,pattern={{major}}.{{minor}},enable=${{ steps.version.outputs.clean == '' }} + type=sha,prefix=sha-,format=short,enable=${{ steps.version.outputs.clean == '' }} - name: 'Log in to the Container registry' if: |- - ${{ github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') || github.event.inputs.publish == 'true') }} + ${{ (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) || (github.event_name == 'workflow_dispatch' && github.event.inputs.publish == 'true') }} uses: 'docker/login-action@v3' # ratchet:exclude with: registry: '${{ env.REGISTRY }}' @@ -60,8 +102,8 @@ jobs: context: '.' platforms: 'linux/amd64,linux/arm64' push: |- - ${{ github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') || github.event.inputs.publish == 'true') }} + ${{ (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) || (github.event_name == 'workflow_dispatch' && github.event.inputs.publish == 'true') }} tags: '${{ steps.meta.outputs.tags }}' labels: '${{ steps.meta.outputs.labels }}' build-args: | - CLI_VERSION_ARG=${{ github.sha }} + CLI_VERSION_ARG=${{ steps.version.outputs.clean || github.sha }} diff --git a/Dockerfile b/Dockerfile index 378880c8d..52a4d4416 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,8 +20,9 @@ COPY . /home/node/app WORKDIR /home/node/app # Install dependencies and build packages +# Use scripts/build.js which handles workspace dependencies in correct order RUN npm ci \ - && npm run build --workspaces \ + && npm run build \ && npm pack -w @qwen-code/qwen-code --pack-destination ./packages/cli/dist \ && npm pack -w @qwen-code/qwen-code-core --pack-destination ./packages/core/dist From b2659c5da1f4f9ef395d20fbf539c88661758b7d Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Thu, 5 Feb 2026 16:40:49 +0800 Subject: [PATCH 36/49] fix(core): make is_background optional with clearer examples - Change is_background from required to optional parameter - Remove explicit is_background:false from examples where it's not needed - Keep is_background:true in examples for long-running background processes - Update test snapshots accordingly Co-authored-by: Qwen-Coder --- .../core/__snapshots__/prompts.test.ts.snap | 64 ++++++++----------- packages/core/src/core/prompts.ts | 14 ++-- packages/core/src/tools/shell.ts | 4 +- 3 files changed, 32 insertions(+), 50 deletions(-) diff --git a/packages/core/src/core/__snapshots__/prompts.test.ts.snap b/packages/core/src/core/__snapshots__/prompts.test.ts.snap index 1072c956a..69481949b 100644 --- a/packages/core/src/core/__snapshots__/prompts.test.ts.snap +++ b/packages/core/src/core/__snapshots__/prompts.test.ts.snap @@ -173,7 +173,7 @@ Here's the plan: [tool_call: edit for path 'src/auth.py' replacing old content with new content] Refactoring complete. Running verification... -[tool_call: run_shell_command for 'ruff check src/auth.py && pytest' with is_background: false] +[tool_call: run_shell_command for 'ruff check src/auth.py && pytest'] (After verification passes) All checks passed. This is a stable checkpoint. Would you like me to write a commit message and commit these changes? @@ -194,7 +194,7 @@ Now I'll look for existing or related test files to understand current testing c (After reviewing existing tests and the file content) [tool_call: write_file for path '/path/to/someFile.test.ts'] I've written the tests. Now I'll run the project's test command to verify them. -[tool_call: run_shell_command for 'npm run test' with is_background: false] +[tool_call: run_shell_command for 'npm run test'] (After verification passes) All checks passed. This is a stable checkpoint. @@ -407,7 +407,7 @@ Here's the plan: [tool_call: edit for path 'src/auth.py' replacing old content with new content] Refactoring complete. Running verification... -[tool_call: run_shell_command for 'ruff check src/auth.py && pytest' with is_background: false] +[tool_call: run_shell_command for 'ruff check src/auth.py && pytest'] (After verification passes) All checks passed. This is a stable checkpoint. Would you like me to write a commit message and commit these changes? @@ -428,7 +428,7 @@ Now I'll look for existing or related test files to understand current testing c (After reviewing existing tests and the file content) [tool_call: write_file for path '/path/to/someFile.test.ts'] I've written the tests. Now I'll run the project's test command to verify them. -[tool_call: run_shell_command for 'npm run test' with is_background: false] +[tool_call: run_shell_command for 'npm run test'] (After verification passes) All checks passed. This is a stable checkpoint. @@ -621,7 +621,7 @@ Here's the plan: [tool_call: edit for path 'src/auth.py' replacing old content with new content] Refactoring complete. Running verification... -[tool_call: run_shell_command for 'ruff check src/auth.py && pytest' with is_background: false] +[tool_call: run_shell_command for 'ruff check src/auth.py && pytest'] (After verification passes) All checks passed. This is a stable checkpoint. Would you like me to write a commit message and commit these changes? @@ -642,7 +642,7 @@ Now I'll look for existing or related test files to understand current testing c (After reviewing existing tests and the file content) [tool_call: write_file for path '/path/to/someFile.test.ts'] I've written the tests. Now I'll run the project's test command to verify them. -[tool_call: run_shell_command for 'npm run test' with is_background: false] +[tool_call: run_shell_command for 'npm run test'] (After verification passes) All checks passed. This is a stable checkpoint. @@ -835,7 +835,7 @@ Here's the plan: [tool_call: edit for path 'src/auth.py' replacing old content with new content] Refactoring complete. Running verification... -[tool_call: run_shell_command for 'ruff check src/auth.py && pytest' with is_background: false] +[tool_call: run_shell_command for 'ruff check src/auth.py && pytest'] (After verification passes) All checks passed. This is a stable checkpoint. Would you like me to write a commit message and commit these changes? @@ -856,7 +856,7 @@ Now I'll look for existing or related test files to understand current testing c (After reviewing existing tests and the file content) [tool_call: write_file for path '/path/to/someFile.test.ts'] I've written the tests. Now I'll run the project's test command to verify them. -[tool_call: run_shell_command for 'npm run test' with is_background: false] +[tool_call: run_shell_command for 'npm run test'] (After verification passes) All checks passed. This is a stable checkpoint. @@ -1049,7 +1049,7 @@ Here's the plan: [tool_call: edit for path 'src/auth.py' replacing old content with new content] Refactoring complete. Running verification... -[tool_call: run_shell_command for 'ruff check src/auth.py && pytest' with is_background: false] +[tool_call: run_shell_command for 'ruff check src/auth.py && pytest'] (After verification passes) All checks passed. This is a stable checkpoint. Would you like me to write a commit message and commit these changes? @@ -1070,7 +1070,7 @@ Now I'll look for existing or related test files to understand current testing c (After reviewing existing tests and the file content) [tool_call: write_file for path '/path/to/someFile.test.ts'] I've written the tests. Now I'll run the project's test command to verify them. -[tool_call: run_shell_command for 'npm run test' with is_background: false] +[tool_call: run_shell_command for 'npm run test'] (After verification passes) All checks passed. This is a stable checkpoint. @@ -1263,7 +1263,7 @@ Here's the plan: [tool_call: edit for path 'src/auth.py' replacing old content with new content] Refactoring complete. Running verification... -[tool_call: run_shell_command for 'ruff check src/auth.py && pytest' with is_background: false] +[tool_call: run_shell_command for 'ruff check src/auth.py && pytest'] (After verification passes) All checks passed. This is a stable checkpoint. Would you like me to write a commit message and commit these changes? @@ -1284,7 +1284,7 @@ Now I'll look for existing or related test files to understand current testing c (After reviewing existing tests and the file content) [tool_call: write_file for path '/path/to/someFile.test.ts'] I've written the tests. Now I'll run the project's test command to verify them. -[tool_call: run_shell_command for 'npm run test' with is_background: false] +[tool_call: run_shell_command for 'npm run test'] (After verification passes) All checks passed. This is a stable checkpoint. @@ -1477,7 +1477,7 @@ Here's the plan: [tool_call: edit for path 'src/auth.py' replacing old content with new content] Refactoring complete. Running verification... -[tool_call: run_shell_command for 'ruff check src/auth.py && pytest' with is_background: false] +[tool_call: run_shell_command for 'ruff check src/auth.py && pytest'] (After verification passes) All checks passed. This is a stable checkpoint. Would you like me to write a commit message and commit these changes? @@ -1498,7 +1498,7 @@ Now I'll look for existing or related test files to understand current testing c (After reviewing existing tests and the file content) [tool_call: write_file for path '/path/to/someFile.test.ts'] I've written the tests. Now I'll run the project's test command to verify them. -[tool_call: run_shell_command for 'npm run test' with is_background: false] +[tool_call: run_shell_command for 'npm run test'] (After verification passes) All checks passed. This is a stable checkpoint. @@ -1691,7 +1691,7 @@ Here's the plan: [tool_call: edit for path 'src/auth.py' replacing old content with new content] Refactoring complete. Running verification... -[tool_call: run_shell_command for 'ruff check src/auth.py && pytest' with is_background: false] +[tool_call: run_shell_command for 'ruff check src/auth.py && pytest'] (After verification passes) All checks passed. This is a stable checkpoint. Would you like me to write a commit message and commit these changes? @@ -1712,7 +1712,7 @@ Now I'll look for existing or related test files to understand current testing c (After reviewing existing tests and the file content) [tool_call: write_file for path '/path/to/someFile.test.ts'] I've written the tests. Now I'll run the project's test command to verify them. -[tool_call: run_shell_command for 'npm run test' with is_background: false] +[tool_call: run_shell_command for 'npm run test'] (After verification passes) All checks passed. This is a stable checkpoint. @@ -1905,7 +1905,7 @@ Here's the plan: [tool_call: edit for path 'src/auth.py' replacing old content with new content] Refactoring complete. Running verification... -[tool_call: run_shell_command for 'ruff check src/auth.py && pytest' with is_background: false] +[tool_call: run_shell_command for 'ruff check src/auth.py && pytest'] (After verification passes) All checks passed. This is a stable checkpoint. Would you like me to write a commit message and commit these changes? @@ -1926,7 +1926,7 @@ Now I'll look for existing or related test files to understand current testing c (After reviewing existing tests and the file content) [tool_call: write_file for path '/path/to/someFile.test.ts'] I've written the tests. Now I'll run the project's test command to verify them. -[tool_call: run_shell_command for 'npm run test' with is_background: false] +[tool_call: run_shell_command for 'npm run test'] (After verification passes) All checks passed. This is a stable checkpoint. @@ -2131,7 +2131,7 @@ Here's the plan: Refactoring complete. Running verification... -{"name": "run_shell_command", "arguments": {"command": "ruff check src/auth.py && pytest", "is_background": false}} +{"name": "run_shell_command", "arguments": {"command": "ruff check src/auth.py && pytest"}} (After verification passes) All checks passed. This is a stable checkpoint. @@ -2160,7 +2160,7 @@ Now I'll look for existing or related test files to understand current testing c I've written the tests. Now I'll run the project's test command to verify them. -{"name": "run_shell_command", "arguments": {"command": "npm run test", "is_background": false}} +{"name": "run_shell_command", "arguments": {"command": "npm run test"}} (After verification passes) All checks passed. This is a stable checkpoint. @@ -2407,9 +2407,6 @@ Refactoring complete. Running verification... ruff check src/auth.py && pytest - -false - (After verification passes) @@ -2455,9 +2452,6 @@ I've written the tests. Now I'll run the project's test command to verify them. npm run test - -false - (After verification passes) @@ -2674,7 +2668,7 @@ Here's the plan: Refactoring complete. Running verification... -{"name": "run_shell_command", "arguments": {"command": "ruff check src/auth.py && pytest", "is_background": false}} +{"name": "run_shell_command", "arguments": {"command": "ruff check src/auth.py && pytest"}} (After verification passes) All checks passed. This is a stable checkpoint. @@ -2703,7 +2697,7 @@ Now I'll look for existing or related test files to understand current testing c I've written the tests. Now I'll run the project's test command to verify them. -{"name": "run_shell_command", "arguments": {"command": "npm run test", "is_background": false}} +{"name": "run_shell_command", "arguments": {"command": "npm run test"}} (After verification passes) All checks passed. This is a stable checkpoint. @@ -2950,9 +2944,6 @@ Refactoring complete. Running verification... ruff check src/auth.py && pytest - -false - (After verification passes) @@ -2998,9 +2989,6 @@ I've written the tests. Now I'll run the project's test command to verify them. npm run test - -false - (After verification passes) @@ -3201,7 +3189,7 @@ Here's the plan: [tool_call: edit for path 'src/auth.py' replacing old content with new content] Refactoring complete. Running verification... -[tool_call: run_shell_command for 'ruff check src/auth.py && pytest' with is_background: false] +[tool_call: run_shell_command for 'ruff check src/auth.py && pytest'] (After verification passes) All checks passed. This is a stable checkpoint. Would you like me to write a commit message and commit these changes? @@ -3222,7 +3210,7 @@ Now I'll look for existing or related test files to understand current testing c (After reviewing existing tests and the file content) [tool_call: write_file for path '/path/to/someFile.test.ts'] I've written the tests. Now I'll run the project's test command to verify them. -[tool_call: run_shell_command for 'npm run test' with is_background: false] +[tool_call: run_shell_command for 'npm run test'] (After verification passes) All checks passed. This is a stable checkpoint. @@ -3415,7 +3403,7 @@ Here's the plan: [tool_call: edit for path 'src/auth.py' replacing old content with new content] Refactoring complete. Running verification... -[tool_call: run_shell_command for 'ruff check src/auth.py && pytest' with is_background: false] +[tool_call: run_shell_command for 'ruff check src/auth.py && pytest'] (After verification passes) All checks passed. This is a stable checkpoint. Would you like me to write a commit message and commit these changes? @@ -3436,7 +3424,7 @@ Now I'll look for existing or related test files to understand current testing c (After reviewing existing tests and the file content) [tool_call: write_file for path '/path/to/someFile.test.ts'] I've written the tests. Now I'll run the project's test command to verify them. -[tool_call: run_shell_command for 'npm run test' with is_background: false] +[tool_call: run_shell_command for 'npm run test'] (After verification passes) All checks passed. This is a stable checkpoint. diff --git a/packages/core/src/core/prompts.ts b/packages/core/src/core/prompts.ts index 20e83cd49..4ad5e38ea 100644 --- a/packages/core/src/core/prompts.ts +++ b/packages/core/src/core/prompts.ts @@ -471,7 +471,7 @@ Here's the plan: [tool_call: ${ToolNames.EDIT} for path 'src/auth.py' replacing old content with new content] Refactoring complete. Running verification... -[tool_call: ${ToolNames.SHELL} for 'ruff check src/auth.py && pytest' with is_background: false] +[tool_call: ${ToolNames.SHELL} for 'ruff check src/auth.py && pytest'] (After verification passes) All checks passed. This is a stable checkpoint. Would you like me to write a commit message and commit these changes? @@ -492,7 +492,7 @@ Now I'll look for existing or related test files to understand current testing c (After reviewing existing tests and the file content) [tool_call: ${ToolNames.WRITE_FILE} for path '/path/to/someFile.test.ts'] I've written the tests. Now I'll run the project's test command to verify them. -[tool_call: ${ToolNames.SHELL} for 'npm run test' with is_background: false] +[tool_call: ${ToolNames.SHELL} for 'npm run test'] (After verification passes) All checks passed. This is a stable checkpoint. @@ -598,9 +598,6 @@ Refactoring complete. Running verification... ruff check src/auth.py && pytest - -false - (After verification passes) @@ -646,9 +643,6 @@ I've written the tests. Now I'll run the project's test command to verify them. npm run test - -false - (After verification passes) @@ -722,7 +716,7 @@ Here's the plan: Refactoring complete. Running verification... -{"name": "${ToolNames.SHELL}", "arguments": {"command": "ruff check src/auth.py && pytest", "is_background": false}} +{"name": "${ToolNames.SHELL}", "arguments": {"command": "ruff check src/auth.py && pytest"}} (After verification passes) All checks passed. This is a stable checkpoint. @@ -751,7 +745,7 @@ Now I'll look for existing or related test files to understand current testing c I've written the tests. Now I'll run the project's test command to verify them. -{"name": "${ToolNames.SHELL}", "arguments": {"command": "npm run test", "is_background": false}} +{"name": "${ToolNames.SHELL}", "arguments": {"command": "npm run test"}} (After verification passes) All checks passed. This is a stable checkpoint. diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index ca898c4c4..d48391b90 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -543,7 +543,7 @@ export class ShellTool extends BaseDeclarativeTool< is_background: { type: 'boolean', description: - 'Whether to run the command in background. This parameter is required to ensure explicit decision-making. Set to true for long-running processes like development servers, watchers, or daemons that should continue running without blocking further commands. Set to false for one-time commands that should complete before proceeding.', + 'Optional: Whether to run the command in background. If not specified, defaults to false (foreground execution). Explicitly set to true for long-running processes like development servers, watchers, or daemons that should continue running without blocking further commands.', }, timeout: { type: 'number', @@ -560,7 +560,7 @@ export class ShellTool extends BaseDeclarativeTool< '(OPTIONAL) The absolute path of the directory to run the command in. If not provided, the project root directory is used. Must be a directory within the workspace and must already exist.', }, }, - required: ['command', 'is_background'], + required: ['command'], }, false, // output is not markdown true, // output can be updated From feeae875a09f6ebcccf8bf0c5ed570d1e4b679e7 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Fri, 16 Jan 2026 17:16:02 +0800 Subject: [PATCH 37/49] feat: add export command draft for session history with markdown and HTML formats --- .../cli/src/services/BuiltinCommandLoader.ts | 2 + .../cli/src/ui/commands/exportCommand.test.ts | 379 ++++++++++++++++ packages/cli/src/ui/commands/exportCommand.ts | 177 ++++++++ packages/cli/src/ui/utils/exportUtils.test.ts | 404 ++++++++++++++++++ packages/cli/src/ui/utils/exportUtils.ts | 167 ++++++++ 5 files changed, 1129 insertions(+) create mode 100644 packages/cli/src/ui/commands/exportCommand.test.ts create mode 100644 packages/cli/src/ui/commands/exportCommand.ts create mode 100644 packages/cli/src/ui/utils/exportUtils.test.ts create mode 100644 packages/cli/src/ui/utils/exportUtils.ts diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 89b742fc2..8e2237766 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -18,6 +18,7 @@ import { copyCommand } from '../ui/commands/copyCommand.js'; import { docsCommand } from '../ui/commands/docsCommand.js'; import { directoryCommand } from '../ui/commands/directoryCommand.js'; import { editorCommand } from '../ui/commands/editorCommand.js'; +import { exportCommand } from '../ui/commands/exportCommand.js'; import { extensionsCommand } from '../ui/commands/extensionsCommand.js'; import { helpCommand } from '../ui/commands/helpCommand.js'; import { ideCommand } from '../ui/commands/ideCommand.js'; @@ -67,6 +68,7 @@ export class BuiltinCommandLoader implements ICommandLoader { docsCommand, directoryCommand, editorCommand, + exportCommand, extensionsCommand, helpCommand, await ideCommand(), diff --git a/packages/cli/src/ui/commands/exportCommand.test.ts b/packages/cli/src/ui/commands/exportCommand.test.ts new file mode 100644 index 000000000..9930a00da --- /dev/null +++ b/packages/cli/src/ui/commands/exportCommand.test.ts @@ -0,0 +1,379 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs/promises'; +import { exportCommand } from './exportCommand.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import type { ChatRecord } from '@qwen-code/qwen-code-core'; +import type { Part, Content } from '@google/genai'; +import { + transformToMarkdown, + loadHtmlTemplate, + prepareExportData, + injectDataIntoHtmlTemplate, + generateExportFilename, +} from '../utils/exportUtils.js'; + +const mockSessionServiceMocks = vi.hoisted(() => ({ + loadLastSession: vi.fn(), +})); + +vi.mock('@qwen-code/qwen-code-core', () => { + class SessionService { + constructor(_cwd: string) {} + async loadLastSession() { + return mockSessionServiceMocks.loadLastSession(); + } + } + + return { + SessionService, + }; +}); + +vi.mock('../utils/exportUtils.js', () => ({ + transformToMarkdown: vi.fn(), + loadHtmlTemplate: vi.fn(), + prepareExportData: vi.fn(), + injectDataIntoHtmlTemplate: vi.fn(), + generateExportFilename: vi.fn(), +})); + +vi.mock('node:fs/promises', () => ({ + writeFile: vi.fn(), +})); + +describe('exportCommand', () => { + const mockSessionData = { + conversation: { + sessionId: 'test-session-id', + startTime: '2025-01-01T00:00:00Z', + messages: [ + { + type: 'user', + message: { + parts: [{ text: 'Hello' }] as Part[], + } as Content, + }, + ] as ChatRecord[], + }, + }; + + let mockContext: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + + mockSessionServiceMocks.loadLastSession.mockResolvedValue(mockSessionData); + + mockContext = createMockCommandContext({ + services: { + config: { + getWorkingDir: vi.fn().mockReturnValue('/test/dir'), + getProjectRoot: vi.fn().mockReturnValue('/test/project'), + }, + }, + }); + + vi.mocked(transformToMarkdown).mockReturnValue('# Test Markdown'); + vi.mocked(loadHtmlTemplate).mockResolvedValue( + '', + ); + vi.mocked(prepareExportData).mockReturnValue({ + sessionId: 'test-session-id', + startTime: '2025-01-01T00:00:00Z', + messages: mockSessionData.conversation.messages, + }); + vi.mocked(injectDataIntoHtmlTemplate).mockReturnValue( + '', + ); + vi.mocked(generateExportFilename).mockImplementation( + (ext: string) => `export-2025-01-01T00-00-00-000Z.${ext}`, + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('command structure', () => { + it('should have correct name and description', () => { + expect(exportCommand.name).toBe('export'); + expect(exportCommand.description).toBe( + 'Export current session message history to a file', + ); + }); + + it('should have md and html subcommands', () => { + expect(exportCommand.subCommands).toHaveLength(2); + expect(exportCommand.subCommands?.map((c) => c.name)).toEqual([ + 'md', + 'html', + ]); + }); + }); + + describe('exportMarkdownAction', () => { + it('should export session to markdown file', async () => { + const mdCommand = exportCommand.subCommands?.find((c) => c.name === 'md'); + if (!mdCommand?.action) { + throw new Error('md command not found'); + } + + const result = await mdCommand.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('export-2025-01-01T00-00-00-000Z.md'), + }); + + expect(mockSessionServiceMocks.loadLastSession).toHaveBeenCalled(); + expect(transformToMarkdown).toHaveBeenCalledWith( + mockSessionData.conversation.messages, + 'test-session-id', + '2025-01-01T00:00:00Z', + ); + expect(generateExportFilename).toHaveBeenCalledWith('md'); + expect(fs.writeFile).toHaveBeenCalledWith( + expect.stringContaining('export-2025-01-01T00-00-00-000Z.md'), + '# Test Markdown', + 'utf-8', + ); + }); + + it('should return error when config is not available', async () => { + const contextWithoutConfig = createMockCommandContext({ + services: { + config: null, + }, + }); + + const mdCommand = exportCommand.subCommands?.find((c) => c.name === 'md'); + if (!mdCommand?.action) { + throw new Error('md command not found'); + } + const result = await mdCommand.action(contextWithoutConfig, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Configuration not available.', + }); + }); + + it('should return error when working directory cannot be determined', async () => { + const contextWithoutCwd = createMockCommandContext({ + services: { + config: { + getWorkingDir: vi.fn().mockReturnValue(null), + getProjectRoot: vi.fn().mockReturnValue(null), + }, + }, + }); + + const mdCommand = exportCommand.subCommands?.find((c) => c.name === 'md'); + if (!mdCommand || !mdCommand.action) { + throw new Error('md command not found'); + } + const result = await mdCommand.action(contextWithoutCwd, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Could not determine current working directory.', + }); + }); + + it('should return error when no session is found', async () => { + mockSessionServiceMocks.loadLastSession.mockResolvedValue(undefined); + + const mdCommand = exportCommand.subCommands?.find((c) => c.name === 'md'); + if (!mdCommand?.action) { + throw new Error('md command not found'); + } + const result = await mdCommand.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'No active session found to export.', + }); + }); + + it('should handle errors during export', async () => { + const error = new Error('File write failed'); + vi.mocked(fs.writeFile).mockRejectedValue(error); + + const mdCommand = exportCommand.subCommands?.find((c) => c.name === 'md'); + if (!mdCommand?.action) { + throw new Error('md command not found'); + } + const result = await mdCommand.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Failed to export session: File write failed', + }); + }); + + it('should use project root when working dir is not available', async () => { + const contextWithProjectRoot = createMockCommandContext({ + services: { + config: { + getWorkingDir: vi.fn().mockReturnValue(null), + getProjectRoot: vi.fn().mockReturnValue('/test/project'), + }, + }, + }); + + const mdCommand = exportCommand.subCommands?.find((c) => c.name === 'md'); + if (!mdCommand?.action) { + throw new Error('md command not found'); + } + await mdCommand.action(contextWithProjectRoot, ''); + }); + }); + + describe('exportHtmlAction', () => { + it('should export session to HTML file', async () => { + const htmlCommand = exportCommand.subCommands?.find( + (c) => c.name === 'html', + ); + if (!htmlCommand?.action) { + throw new Error('html command not found'); + } + + const result = await htmlCommand.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining( + 'export-2025-01-01T00-00-00-000Z.html', + ), + }); + + expect(mockSessionServiceMocks.loadLastSession).toHaveBeenCalled(); + expect(loadHtmlTemplate).toHaveBeenCalled(); + expect(prepareExportData).toHaveBeenCalledWith( + mockSessionData.conversation, + ); + expect(injectDataIntoHtmlTemplate).toHaveBeenCalled(); + expect(generateExportFilename).toHaveBeenCalledWith('html'); + expect(fs.writeFile).toHaveBeenCalledWith( + expect.stringContaining('export-2025-01-01T00-00-00-000Z.html'), + expect.stringContaining('{"data": "test"}'), + 'utf-8', + ); + }); + + it('should return error when config is not available', async () => { + const contextWithoutConfig = createMockCommandContext({ + services: { + config: null, + }, + }); + + const htmlCommand = exportCommand.subCommands?.find( + (c) => c.name === 'html', + ); + if (!htmlCommand?.action) { + throw new Error('html command not found'); + } + const result = await htmlCommand.action(contextWithoutConfig, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Configuration not available.', + }); + }); + + it('should return error when working directory cannot be determined', async () => { + const contextWithoutCwd = createMockCommandContext({ + services: { + config: { + getWorkingDir: vi.fn().mockReturnValue(null), + getProjectRoot: vi.fn().mockReturnValue(null), + }, + }, + }); + + const htmlCommand = exportCommand.subCommands?.find( + (c) => c.name === 'html', + ); + if (!htmlCommand || !htmlCommand.action) { + throw new Error('html command not found'); + } + const result = await htmlCommand.action(contextWithoutCwd, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Could not determine current working directory.', + }); + }); + + it('should return error when no session is found', async () => { + mockSessionServiceMocks.loadLastSession.mockResolvedValue(undefined); + + const htmlCommand = exportCommand.subCommands?.find( + (c) => c.name === 'html', + ); + if (!htmlCommand?.action) { + throw new Error('html command not found'); + } + const result = await htmlCommand.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'No active session found to export.', + }); + }); + + it('should handle errors during HTML template loading', async () => { + const error = new Error('Failed to fetch template'); + vi.mocked(loadHtmlTemplate).mockRejectedValue(error); + + const htmlCommand = exportCommand.subCommands?.find( + (c) => c.name === 'html', + ); + if (!htmlCommand?.action) { + throw new Error('html command not found'); + } + const result = await htmlCommand.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Failed to export session: Failed to fetch template', + }); + }); + + it('should handle errors during file write', async () => { + const error = new Error('File write failed'); + vi.mocked(fs.writeFile).mockRejectedValue(error); + + const htmlCommand = exportCommand.subCommands?.find( + (c) => c.name === 'html', + ); + if (!htmlCommand?.action) { + throw new Error('html command not found'); + } + const result = await htmlCommand.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Failed to export session: File write failed', + }); + }); + }); +}); diff --git a/packages/cli/src/ui/commands/exportCommand.ts b/packages/cli/src/ui/commands/exportCommand.ts new file mode 100644 index 000000000..88d5289c3 --- /dev/null +++ b/packages/cli/src/ui/commands/exportCommand.ts @@ -0,0 +1,177 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import path from 'node:path'; +import { + type CommandContext, + type SlashCommand, + type MessageActionReturn, + CommandKind, +} from './types.js'; +import { SessionService } from '@qwen-code/qwen-code-core'; +import { + transformToMarkdown, + loadHtmlTemplate, + prepareExportData, + injectDataIntoHtmlTemplate, + generateExportFilename, +} from '../utils/exportUtils.js'; + +/** + * Action for the 'md' subcommand - exports session to markdown. + */ +async function exportMarkdownAction( + context: CommandContext, +): Promise { + const { services } = context; + const { config } = services; + + if (!config) { + return { + type: 'message', + messageType: 'error', + content: 'Configuration not available.', + }; + } + + const cwd = config.getWorkingDir() || config.getProjectRoot(); + if (!cwd) { + return { + type: 'message', + messageType: 'error', + content: 'Could not determine current working directory.', + }; + } + + try { + // Load the current session + const sessionService = new SessionService(cwd); + const sessionData = await sessionService.loadLastSession(); + + if (!sessionData) { + return { + type: 'message', + messageType: 'error', + content: 'No active session found to export.', + }; + } + + const { conversation } = sessionData; + + const markdown = transformToMarkdown( + conversation.messages, + conversation.sessionId, + conversation.startTime, + ); + + const filename = generateExportFilename('md'); + const filepath = path.join(cwd, filename); + + // Write to file + await fs.writeFile(filepath, markdown, 'utf-8'); + + return { + type: 'message', + messageType: 'info', + content: `Session exported to markdown: ${filename}`, + }; + } catch (error) { + return { + type: 'message', + messageType: 'error', + content: `Failed to export session: ${error instanceof Error ? error.message : String(error)}`, + }; + } +} + +/** + * Action for the 'html' subcommand - exports session to HTML. + */ +async function exportHtmlAction( + context: CommandContext, +): Promise { + const { services } = context; + const { config } = services; + + if (!config) { + return { + type: 'message', + messageType: 'error', + content: 'Configuration not available.', + }; + } + + const cwd = config.getWorkingDir() || config.getProjectRoot(); + if (!cwd) { + return { + type: 'message', + messageType: 'error', + content: 'Could not determine current working directory.', + }; + } + + try { + // Load the current session + const sessionService = new SessionService(cwd); + const sessionData = await sessionService.loadLastSession(); + + if (!sessionData) { + return { + type: 'message', + messageType: 'error', + content: 'No active session found to export.', + }; + } + + const { conversation } = sessionData; + + const template = await loadHtmlTemplate(); + const exportData = prepareExportData(conversation); + const html = injectDataIntoHtmlTemplate(template, exportData); + + const filename = generateExportFilename('html'); + const filepath = path.join(cwd, filename); + + // Write to file + await fs.writeFile(filepath, html, 'utf-8'); + + return { + type: 'message', + messageType: 'info', + content: `Session exported to HTML: ${filename}`, + }; + } catch (error) { + return { + type: 'message', + messageType: 'error', + content: `Failed to export session: ${error instanceof Error ? error.message : String(error)}`, + }; + } +} + +/** + * Main export command with subcommands. + */ +export const exportCommand: SlashCommand = { + name: 'export', + description: 'Export current session message history to a file', + kind: CommandKind.BUILT_IN, + subCommands: [ + { + name: 'md', + description: 'Export session to markdown format', + kind: CommandKind.BUILT_IN, + action: exportMarkdownAction, + }, + { + name: 'html', + description: 'Export session to HTML format', + kind: CommandKind.BUILT_IN, + action: exportHtmlAction, + }, + ], +}; diff --git a/packages/cli/src/ui/utils/exportUtils.test.ts b/packages/cli/src/ui/utils/exportUtils.test.ts new file mode 100644 index 000000000..8a8fcb046 --- /dev/null +++ b/packages/cli/src/ui/utils/exportUtils.test.ts @@ -0,0 +1,404 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + extractTextFromContent, + transformToMarkdown, + loadHtmlTemplate, + prepareExportData, + injectDataIntoHtmlTemplate, + generateExportFilename, +} from './exportUtils.js'; +import type { ChatRecord } from '@qwen-code/qwen-code-core'; +import type { Part, Content } from '@google/genai'; + +describe('exportUtils', () => { + describe('extractTextFromContent', () => { + it('should return empty string for undefined content', () => { + expect(extractTextFromContent(undefined)).toBe(''); + }); + + it('should return empty string for content without parts', () => { + expect(extractTextFromContent({} as Content)).toBe(''); + }); + + it('should extract text from text parts', () => { + const content: Content = { + parts: [{ text: 'Hello' }, { text: 'World' }] as Part[], + }; + expect(extractTextFromContent(content)).toBe('Hello\nWorld'); + }); + + it('should format function call parts', () => { + const content: Content = { + parts: [ + { + functionCall: { + name: 'testFunction', + args: { param1: 'value1' }, + }, + }, + ] as Part[], + }; + const result = extractTextFromContent(content); + expect(result).toContain('[Function Call: testFunction]'); + expect(result).toContain('"param1": "value1"'); + }); + + it('should format function response parts', () => { + const content: Content = { + parts: [ + { + functionResponse: { + name: 'testFunction', + response: { result: 'success' }, + }, + }, + ] as Part[], + }; + const result = extractTextFromContent(content); + expect(result).toContain('[Function Response: testFunction]'); + expect(result).toContain('"result": "success"'); + }); + + it('should handle mixed part types', () => { + const content: Content = { + parts: [ + { text: 'Start' }, + { + functionCall: { + name: 'call', + args: {}, + }, + }, + { text: 'End' }, + ] as Part[], + }; + const result = extractTextFromContent(content); + expect(result).toContain('Start'); + expect(result).toContain('[Function Call: call]'); + expect(result).toContain('End'); + }); + }); + + describe('transformToMarkdown', () => { + const mockMessages: ChatRecord[] = [ + { + uuid: 'uuid-1', + parentUuid: null, + sessionId: 'test-session-id', + timestamp: '2025-01-01T00:00:00Z', + type: 'user', + cwd: '/test', + version: '1.0.0', + message: { + parts: [{ text: 'Hello, how are you?' }] as Part[], + } as Content, + }, + { + uuid: 'uuid-2', + parentUuid: 'uuid-1', + sessionId: 'test-session-id', + timestamp: '2025-01-01T00:00:01Z', + type: 'assistant', + cwd: '/test', + version: '1.0.0', + message: { + parts: [{ text: 'I am doing well, thank you!' }] as Part[], + } as Content, + }, + ]; + + it('should transform messages to markdown format', () => { + const result = transformToMarkdown( + mockMessages, + 'test-session-id', + '2025-01-01T00:00:00Z', + ); + + expect(result).toContain('# Chat Session Export'); + expect(result).toContain('**Session ID**: test-session-id'); + expect(result).toContain('**Start Time**: 2025-01-01T00:00:00Z'); + expect(result).toContain('## User'); + expect(result).toContain('Hello, how are you?'); + expect(result).toContain('## Assistant'); + expect(result).toContain('I am doing well, thank you!'); + }); + + it('should include exported timestamp', () => { + const before = new Date().toISOString(); + const result = transformToMarkdown( + mockMessages, + 'test-session-id', + '2025-01-01T00:00:00Z', + ); + const after = new Date().toISOString(); + + expect(result).toContain('**Exported**:'); + const exportedMatch = result.match(/\*\*Exported\*\*: (.+)/); + expect(exportedMatch).toBeTruthy(); + if (exportedMatch) { + const exportedTime = exportedMatch[1].trim(); + expect(exportedTime >= before).toBe(true); + expect(exportedTime <= after).toBe(true); + } + }); + + it('should format tool_result messages', () => { + const messages: ChatRecord[] = [ + { + uuid: 'uuid-3', + parentUuid: 'uuid-2', + sessionId: 'test-session-id', + timestamp: '2025-01-01T00:00:02Z', + type: 'tool_result', + cwd: '/test', + version: '1.0.0', + toolCallResult: { + resultDisplay: 'Tool output', + }, + message: { + parts: [{ text: 'Additional info' }] as Part[], + } as Content, + }, + ]; + + const result = transformToMarkdown( + messages, + 'test-session-id', + '2025-01-01T00:00:00Z', + ); + + expect(result).toContain('## Tool Result'); + expect(result).toContain('```'); + expect(result).toContain('Tool output'); + expect(result).toContain('Additional info'); + }); + + it('should format tool_result with JSON resultDisplay', () => { + const messages: ChatRecord[] = [ + { + uuid: 'uuid-4', + parentUuid: 'uuid-3', + sessionId: 'test-session-id', + timestamp: '2025-01-01T00:00:03Z', + type: 'tool_result', + cwd: '/test', + version: '1.0.0', + toolCallResult: { + resultDisplay: '{"key": "value"}', + }, + message: {} as Content, + }, + ]; + + const result = transformToMarkdown( + messages, + 'test-session-id', + '2025-01-01T00:00:00Z', + ); + + expect(result).toContain('## Tool Result'); + expect(result).toContain('```'); + expect(result).toContain('"key": "value"'); + }); + + it('should handle chat compression system messages', () => { + const messages: ChatRecord[] = [ + { + uuid: 'uuid-5', + parentUuid: null, + sessionId: 'test-session-id', + timestamp: '2025-01-01T00:00:04Z', + type: 'system', + subtype: 'chat_compression', + cwd: '/test', + version: '1.0.0', + message: {} as Content, + }, + ]; + + const result = transformToMarkdown( + messages, + 'test-session-id', + '2025-01-01T00:00:00Z', + ); + + expect(result).toContain('_[Chat history compressed]_'); + }); + + it('should skip system messages without subtype', () => { + const messages: ChatRecord[] = [ + { + uuid: 'uuid-6', + parentUuid: null, + sessionId: 'test-session-id', + timestamp: '2025-01-01T00:00:05Z', + type: 'system', + cwd: '/test', + version: '1.0.0', + message: {} as Content, + }, + ]; + + const result = transformToMarkdown( + messages, + 'test-session-id', + '2025-01-01T00:00:00Z', + ); + + expect(result).not.toContain('## System'); + }); + }); + + describe('loadHtmlTemplate', () => { + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('should load HTML template from URL', async () => { + const mockTemplate = 'Test Template'; + const mockResponse = { + ok: true, + text: vi.fn().mockResolvedValue(mockTemplate), + }; + vi.mocked(fetch).mockResolvedValue(mockResponse as unknown as Response); + + const result = await loadHtmlTemplate(); + + expect(result).toBe(mockTemplate); + expect(fetch).toHaveBeenCalledWith( + 'https://raw.githubusercontent.com/QwenLM/qwen-code/main/template_portable.html', + ); + }); + + it('should throw error when fetch fails', async () => { + const mockResponse = { + ok: false, + status: 404, + statusText: 'Not Found', + }; + vi.mocked(fetch).mockResolvedValue(mockResponse as unknown as Response); + + await expect(loadHtmlTemplate()).rejects.toThrow( + 'Failed to fetch HTML template: 404 Not Found', + ); + }); + + it('should throw error when network request fails', async () => { + const networkError = new Error('Network error'); + vi.mocked(fetch).mockRejectedValue(networkError); + + await expect(loadHtmlTemplate()).rejects.toThrow( + 'Failed to load HTML template', + ); + await expect(loadHtmlTemplate()).rejects.toThrow('Network error'); + }); + }); + + describe('prepareExportData', () => { + it('should prepare export data from conversation', () => { + const conversation = { + sessionId: 'test-session-id', + startTime: '2025-01-01T00:00:00Z', + messages: [ + { + type: 'user', + message: { + parts: [{ text: 'Hello' }] as Part[], + } as Content, + }, + ] as ChatRecord[], + }; + + const result = prepareExportData(conversation); + + expect(result).toEqual({ + sessionId: 'test-session-id', + startTime: '2025-01-01T00:00:00Z', + messages: conversation.messages, + }); + }); + }); + + describe('injectDataIntoHtmlTemplate', () => { + it('should inject JSON data into HTML template', () => { + const template = ` + + + + + + `; + + const data = { + sessionId: 'test-session-id', + startTime: '2025-01-01T00:00:00Z', + messages: [] as ChatRecord[], + }; + + const result = injectDataIntoHtmlTemplate(template, data); + + expect(result).toContain( + '`; + + const data = { + sessionId: 'test', + startTime: '2025-01-01T00:00:00Z', + messages: [] as ChatRecord[], + }; + + const result = injectDataIntoHtmlTemplate(template, data); + + expect(result).toContain('"sessionId": "test"'); + expect(result).not.toContain('DATA_PLACEHOLDER'); + }); + }); + + describe('generateExportFilename', () => { + it('should generate filename with timestamp and extension', () => { + const filename = generateExportFilename('md'); + + expect(filename).toMatch( + /^export-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z\.md$/, + ); + }); + + it('should use provided extension', () => { + const filename1 = generateExportFilename('html'); + const filename2 = generateExportFilename('json'); + + expect(filename1).toMatch(/\.html$/); + expect(filename2).toMatch(/\.json$/); + }); + + it('should replace colons and dots in timestamp', () => { + const filename = generateExportFilename('md'); + + expect(filename).not.toContain(':'); + // The filename should contain a dot only for the extension + expect(filename.split('.').length).toBe(2); + // Check that timestamp part (before extension) doesn't contain dots + const timestampPart = filename.split('.')[0]; + expect(timestampPart).not.toContain('.'); + }); + }); +}); diff --git a/packages/cli/src/ui/utils/exportUtils.ts b/packages/cli/src/ui/utils/exportUtils.ts new file mode 100644 index 000000000..165e55996 --- /dev/null +++ b/packages/cli/src/ui/utils/exportUtils.ts @@ -0,0 +1,167 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Part, Content } from '@google/genai'; +import type { ChatRecord } from '@qwen-code/qwen-code-core'; + +const HTML_TEMPLATE_URL = + 'https://raw.githubusercontent.com/QwenLM/qwen-code/main/template_portable.html'; + +/** + * Extracts text content from a Content object's parts. + */ +export function extractTextFromContent(content: Content | undefined): string { + if (!content?.parts) return ''; + + const textParts: string[] = []; + for (const part of content.parts as Part[]) { + if ('text' in part) { + const textPart = part as { text: string }; + textParts.push(textPart.text); + } else if ('functionCall' in part) { + const fnPart = part as { functionCall: { name: string; args: unknown } }; + textParts.push( + `[Function Call: ${fnPart.functionCall.name}]\n${JSON.stringify(fnPart.functionCall.args, null, 2)}`, + ); + } else if ('functionResponse' in part) { + const fnResPart = part as { + functionResponse: { name: string; response: unknown }; + }; + textParts.push( + `[Function Response: ${fnResPart.functionResponse.name}]\n${JSON.stringify(fnResPart.functionResponse.response, null, 2)}`, + ); + } + } + + return textParts.join('\n'); +} + +/** + * Transforms ChatRecord messages to markdown format. + */ +export function transformToMarkdown( + messages: ChatRecord[], + sessionId: string, + startTime: string, +): string { + const lines: string[] = []; + + // Add header with metadata + lines.push('# Chat Session Export\n'); + lines.push(`**Session ID**: ${sessionId}\n`); + lines.push(`**Start Time**: ${startTime}\n`); + lines.push(`**Exported**: ${new Date().toISOString()}\n`); + lines.push('---\n'); + + // Process each message + for (const record of messages) { + if (record.type === 'user') { + lines.push('## User\n'); + const text = extractTextFromContent(record.message); + lines.push(`${text}\n`); + } else if (record.type === 'assistant') { + lines.push('## Assistant\n'); + const text = extractTextFromContent(record.message); + lines.push(`${text}\n`); + } else if (record.type === 'tool_result') { + lines.push('## Tool Result\n'); + if (record.toolCallResult) { + const resultDisplay = record.toolCallResult.resultDisplay; + if (resultDisplay) { + lines.push('```\n'); + lines.push( + typeof resultDisplay === 'string' + ? resultDisplay + : JSON.stringify(resultDisplay, null, 2), + ); + lines.push('\n```\n'); + } + } + const text = extractTextFromContent(record.message); + if (text) { + lines.push(`${text}\n`); + } + } else if (record.type === 'system') { + // Skip system messages or format them minimally + if (record.subtype === 'chat_compression') { + lines.push('_[Chat history compressed]_\n'); + } + } + + lines.push('\n'); + } + + return lines.join(''); +} + +/** + * Loads the HTML template from a remote URL via fetch. + * Throws an error if the fetch fails. + */ +export async function loadHtmlTemplate(): Promise { + try { + const response = await fetch(HTML_TEMPLATE_URL); + if (!response.ok) { + throw new Error( + `Failed to fetch HTML template: ${response.status} ${response.statusText}`, + ); + } + const template = await response.text(); + return template; + } catch (error) { + throw new Error( + `Failed to load HTML template from ${HTML_TEMPLATE_URL}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } +} + +/** + * Prepares export data from conversation. + */ +export function prepareExportData(conversation: { + sessionId: string; + startTime: string; + messages: ChatRecord[]; +}): { + sessionId: string; + startTime: string; + messages: ChatRecord[]; +} { + return { + sessionId: conversation.sessionId, + startTime: conversation.startTime, + messages: conversation.messages, + }; +} + +/** + * Injects JSON data into the HTML template. + */ +export function injectDataIntoHtmlTemplate( + template: string, + data: { + sessionId: string; + startTime: string; + messages: ChatRecord[]; + }, +): string { + const jsonData = JSON.stringify(data, null, 2); + const html = template.replace( + /`, + ); + return html; +} + +/** + * Generates a filename with timestamp for export files. + */ +export function generateExportFilename(extension: string): string { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + return `export-${timestamp}.${extension}`; +} From 660017706fa198604c1ff6e28dcfe086e939cabb Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Mon, 26 Jan 2026 13:32:30 +0800 Subject: [PATCH 38/49] fix: update exportUtils to use embedded html template --- packages/cli/src/ui/utils/exportUtils.test.ts | 85 ++++--- packages/cli/src/ui/utils/exportUtils.ts | 224 ++++++++++++++++-- 2 files changed, 244 insertions(+), 65 deletions(-) diff --git a/packages/cli/src/ui/utils/exportUtils.test.ts b/packages/cli/src/ui/utils/exportUtils.test.ts index 8a8fcb046..b58b5a73e 100644 --- a/packages/cli/src/ui/utils/exportUtils.test.ts +++ b/packages/cli/src/ui/utils/exportUtils.test.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect } from 'vitest'; import { extractTextFromContent, transformToMarkdown, @@ -256,51 +256,12 @@ describe('exportUtils', () => { }); describe('loadHtmlTemplate', () => { - beforeEach(() => { - vi.stubGlobal('fetch', vi.fn()); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it('should load HTML template from URL', async () => { - const mockTemplate = 'Test Template'; - const mockResponse = { - ok: true, - text: vi.fn().mockResolvedValue(mockTemplate), - }; - vi.mocked(fetch).mockResolvedValue(mockResponse as unknown as Response); - + it('should load HTML template from bundled constant', async () => { const result = await loadHtmlTemplate(); - expect(result).toBe(mockTemplate); - expect(fetch).toHaveBeenCalledWith( - 'https://raw.githubusercontent.com/QwenLM/qwen-code/main/template_portable.html', - ); - }); - - it('should throw error when fetch fails', async () => { - const mockResponse = { - ok: false, - status: 404, - statusText: 'Not Found', - }; - vi.mocked(fetch).mockResolvedValue(mockResponse as unknown as Response); - - await expect(loadHtmlTemplate()).rejects.toThrow( - 'Failed to fetch HTML template: 404 Not Found', - ); - }); - - it('should throw error when network request fails', async () => { - const networkError = new Error('Network error'); - vi.mocked(fetch).mockRejectedValue(networkError); - - await expect(loadHtmlTemplate()).rejects.toThrow( - 'Failed to load HTML template', - ); - await expect(loadHtmlTemplate()).rejects.toThrow('Network error'); + expect(result).toContain(''); + expect(result).toContain('Qwen Code Chat Export'); + expect(result).toContain('id="chat-data"'); }); }); @@ -371,6 +332,42 @@ describe('exportUtils', () => { expect(result).toContain('"sessionId": "test"'); expect(result).not.toContain('DATA_PLACEHOLDER'); }); + + it('should escape unsafe JSON sequences in HTML', () => { + const template = ` + + + + + + `; + + const data = { + sessionId: 'test-session-id', + startTime: '2025-01-01T00:00:00Z', + messages: [ + { + uuid: 'uuid-1', + parentUuid: null, + sessionId: 'test-session-id', + timestamp: '2025-01-01T00:00:00Z', + type: 'user', + cwd: '/test', + version: '1.0.0', + message: { + parts: [{ text: '

unsafe
' }] as Part[], + } as Content, + }, + ] as ChatRecord[], + }; + + const result = injectDataIntoHtmlTemplate(template, data); + + expect(result).toContain('\\u003c/script'); + expect(result).not.toContain('
unsafe
'); + }); }); describe('generateExportFilename', () => { diff --git a/packages/cli/src/ui/utils/exportUtils.ts b/packages/cli/src/ui/utils/exportUtils.ts index 165e55996..8e3d53ba4 100644 --- a/packages/cli/src/ui/utils/exportUtils.ts +++ b/packages/cli/src/ui/utils/exportUtils.ts @@ -7,8 +7,205 @@ import type { Part, Content } from '@google/genai'; import type { ChatRecord } from '@qwen-code/qwen-code-core'; -const HTML_TEMPLATE_URL = - 'https://raw.githubusercontent.com/QwenLM/qwen-code/main/template_portable.html'; +const HTML_TEMPLATE = ` + + + + + + Qwen Code Chat Export + + + + + + + + + + + + + + + + + +
+
+

Qwen Code Chat Export

+
+
+ Session ID: + - +
+
+ Date: + - +
+
+
+ +
+
+ + + + + + + +`; + +function escapeJsonForHtml(json: string): string { + return json + .replace(/&/g, '\\u0026') + .replace(//g, '\\u003e'); +} /** * Extracts text content from a Content object's parts. @@ -98,26 +295,10 @@ export function transformToMarkdown( } /** - * Loads the HTML template from a remote URL via fetch. - * Throws an error if the fetch fails. + * Loads the HTML template from a bundled string constant. */ export async function loadHtmlTemplate(): Promise { - try { - const response = await fetch(HTML_TEMPLATE_URL); - if (!response.ok) { - throw new Error( - `Failed to fetch HTML template: ${response.status} ${response.statusText}`, - ); - } - const template = await response.text(); - return template; - } catch (error) { - throw new Error( - `Failed to load HTML template from ${HTML_TEMPLATE_URL}: ${ - error instanceof Error ? error.message : String(error) - }`, - ); - } + return HTML_TEMPLATE; } /** @@ -151,9 +332,10 @@ export function injectDataIntoHtmlTemplate( }, ): string { const jsonData = JSON.stringify(data, null, 2); + const escapedJsonData = escapeJsonForHtml(jsonData); const html = template.replace( /`, + ``, ); return html; } From a4630d39e483061958f9861c2c249c4efbc46872 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Wed, 28 Jan 2026 15:20:55 +0800 Subject: [PATCH 39/49] feat(export): support html/json/jsonl/md export --- .../session/HistoryReplayer.ts | 13 + .../cli/src/ui/commands/exportCommand.test.ts | 64 +-- packages/cli/src/ui/commands/exportCommand.ts | 192 ++++++++- packages/cli/src/ui/utils/export/collect.ts | 205 +++++++++ .../src/ui/utils/export/formatters/html.ts | 55 +++ .../utils/export/formatters/htmlTemplate.ts | 362 ++++++++++++++++ .../src/ui/utils/export/formatters/json.ts | 15 + .../src/ui/utils/export/formatters/jsonl.ts | 31 ++ .../ui/utils/export/formatters/markdown.ts | 86 ++++ packages/cli/src/ui/utils/export/index.ts | 18 + packages/cli/src/ui/utils/export/normalize.ts | 291 +++++++++++++ packages/cli/src/ui/utils/export/types.ts | 54 +++ packages/cli/src/ui/utils/export/utils.ts | 13 + packages/cli/src/ui/utils/exportUtils.test.ts | 401 ------------------ packages/cli/src/ui/utils/exportUtils.ts | 349 --------------- 15 files changed, 1358 insertions(+), 791 deletions(-) create mode 100644 packages/cli/src/ui/utils/export/collect.ts create mode 100644 packages/cli/src/ui/utils/export/formatters/html.ts create mode 100644 packages/cli/src/ui/utils/export/formatters/htmlTemplate.ts create mode 100644 packages/cli/src/ui/utils/export/formatters/json.ts create mode 100644 packages/cli/src/ui/utils/export/formatters/jsonl.ts create mode 100644 packages/cli/src/ui/utils/export/formatters/markdown.ts create mode 100644 packages/cli/src/ui/utils/export/index.ts create mode 100644 packages/cli/src/ui/utils/export/normalize.ts create mode 100644 packages/cli/src/ui/utils/export/types.ts create mode 100644 packages/cli/src/ui/utils/export/utils.ts delete mode 100644 packages/cli/src/ui/utils/exportUtils.test.ts delete mode 100644 packages/cli/src/ui/utils/exportUtils.ts diff --git a/packages/cli/src/acp-integration/session/HistoryReplayer.ts b/packages/cli/src/acp-integration/session/HistoryReplayer.ts index 0ecbccb9b..f668f1986 100644 --- a/packages/cli/src/acp-integration/session/HistoryReplayer.ts +++ b/packages/cli/src/acp-integration/session/HistoryReplayer.ts @@ -21,10 +21,12 @@ import { ToolCallEmitter } from './emitters/ToolCallEmitter.js'; * have appeared during the original session. */ export class HistoryReplayer { + private readonly ctx: SessionContext; private readonly messageEmitter: MessageEmitter; private readonly toolCallEmitter: ToolCallEmitter; constructor(ctx: SessionContext) { + this.ctx = ctx; this.messageEmitter = new MessageEmitter(ctx); this.toolCallEmitter = new ToolCallEmitter(ctx); } @@ -44,6 +46,7 @@ export class HistoryReplayer { * Replays a single chat record. */ private async replayRecord(record: ChatRecord): Promise { + this.setActiveRecordId(record.uuid); switch (record.type) { case 'user': if (record.message) { @@ -68,6 +71,7 @@ export class HistoryReplayer { // Skip system records (compression, telemetry, slash commands) break; } + this.setActiveRecordId(null); } /** @@ -199,4 +203,13 @@ export class HistoryReplayer { } return ''; } + + private setActiveRecordId(recordId: string | null): void { + const context = this.ctx as unknown as { + setActiveRecordId?: (id: string | null) => void; + }; + if (typeof context.setActiveRecordId === 'function') { + context.setActiveRecordId(recordId); + } + } } diff --git a/packages/cli/src/ui/commands/exportCommand.test.ts b/packages/cli/src/ui/commands/exportCommand.test.ts index 9930a00da..b43ddfef4 100644 --- a/packages/cli/src/ui/commands/exportCommand.test.ts +++ b/packages/cli/src/ui/commands/exportCommand.test.ts @@ -11,12 +11,12 @@ import { createMockCommandContext } from '../../test-utils/mockCommandContext.js import type { ChatRecord } from '@qwen-code/qwen-code-core'; import type { Part, Content } from '@google/genai'; import { - transformToMarkdown, - loadHtmlTemplate, - prepareExportData, - injectDataIntoHtmlTemplate, + collectSessionData, + normalizeSessionData, + toMarkdown, + toHtml, generateExportFilename, -} from '../utils/exportUtils.js'; +} from '../utils/export/index.js'; const mockSessionServiceMocks = vi.hoisted(() => ({ loadLastSession: vi.fn(), @@ -35,11 +35,11 @@ vi.mock('@qwen-code/qwen-code-core', () => { }; }); -vi.mock('../utils/exportUtils.js', () => ({ - transformToMarkdown: vi.fn(), - loadHtmlTemplate: vi.fn(), - prepareExportData: vi.fn(), - injectDataIntoHtmlTemplate: vi.fn(), +vi.mock('../utils/export/index.js', () => ({ + collectSessionData: vi.fn(), + normalizeSessionData: vi.fn(), + toMarkdown: vi.fn(), + toHtml: vi.fn(), generateExportFilename: vi.fn(), })); @@ -79,16 +79,14 @@ describe('exportCommand', () => { }, }); - vi.mocked(transformToMarkdown).mockReturnValue('# Test Markdown'); - vi.mocked(loadHtmlTemplate).mockResolvedValue( - '', - ); - vi.mocked(prepareExportData).mockReturnValue({ + vi.mocked(collectSessionData).mockResolvedValue({ sessionId: 'test-session-id', startTime: '2025-01-01T00:00:00Z', - messages: mockSessionData.conversation.messages, + messages: [], }); - vi.mocked(injectDataIntoHtmlTemplate).mockReturnValue( + vi.mocked(normalizeSessionData).mockImplementation((data) => data); + vi.mocked(toMarkdown).mockReturnValue('# Test Markdown'); + vi.mocked(toHtml).mockReturnValue( '', ); vi.mocked(generateExportFilename).mockImplementation( @@ -108,11 +106,13 @@ describe('exportCommand', () => { ); }); - it('should have md and html subcommands', () => { - expect(exportCommand.subCommands).toHaveLength(2); + it('should have md, html, json, and jsonl subcommands', () => { + expect(exportCommand.subCommands).toHaveLength(4); expect(exportCommand.subCommands?.map((c) => c.name)).toEqual([ 'md', 'html', + 'json', + 'jsonl', ]); }); }); @@ -133,11 +133,12 @@ describe('exportCommand', () => { }); expect(mockSessionServiceMocks.loadLastSession).toHaveBeenCalled(); - expect(transformToMarkdown).toHaveBeenCalledWith( - mockSessionData.conversation.messages, - 'test-session-id', - '2025-01-01T00:00:00Z', + expect(collectSessionData).toHaveBeenCalledWith( + mockSessionData.conversation, + expect.anything(), ); + expect(normalizeSessionData).toHaveBeenCalled(); + expect(toMarkdown).toHaveBeenCalled(); expect(generateExportFilename).toHaveBeenCalledWith('md'); expect(fs.writeFile).toHaveBeenCalledWith( expect.stringContaining('export-2025-01-01T00-00-00-000Z.md'), @@ -260,11 +261,12 @@ describe('exportCommand', () => { }); expect(mockSessionServiceMocks.loadLastSession).toHaveBeenCalled(); - expect(loadHtmlTemplate).toHaveBeenCalled(); - expect(prepareExportData).toHaveBeenCalledWith( + expect(collectSessionData).toHaveBeenCalledWith( mockSessionData.conversation, + expect.anything(), ); - expect(injectDataIntoHtmlTemplate).toHaveBeenCalled(); + expect(normalizeSessionData).toHaveBeenCalled(); + expect(toHtml).toHaveBeenCalled(); expect(generateExportFilename).toHaveBeenCalledWith('html'); expect(fs.writeFile).toHaveBeenCalledWith( expect.stringContaining('export-2025-01-01T00-00-00-000Z.html'), @@ -338,9 +340,11 @@ describe('exportCommand', () => { }); }); - it('should handle errors during HTML template loading', async () => { - const error = new Error('Failed to fetch template'); - vi.mocked(loadHtmlTemplate).mockRejectedValue(error); + it('should handle errors during HTML generation', async () => { + const error = new Error('Failed to generate HTML'); + vi.mocked(toHtml).mockImplementation(() => { + throw error; + }); const htmlCommand = exportCommand.subCommands?.find( (c) => c.name === 'html', @@ -353,7 +357,7 @@ describe('exportCommand', () => { expect(result).toEqual({ type: 'message', messageType: 'error', - content: 'Failed to export session: Failed to fetch template', + content: 'Failed to export session: Failed to generate HTML', }); }); diff --git a/packages/cli/src/ui/commands/exportCommand.ts b/packages/cli/src/ui/commands/exportCommand.ts index 88d5289c3..0e5160f41 100644 --- a/packages/cli/src/ui/commands/exportCommand.ts +++ b/packages/cli/src/ui/commands/exportCommand.ts @@ -14,12 +14,14 @@ import { } from './types.js'; import { SessionService } from '@qwen-code/qwen-code-core'; import { - transformToMarkdown, - loadHtmlTemplate, - prepareExportData, - injectDataIntoHtmlTemplate, + collectSessionData, + normalizeSessionData, + toMarkdown, + toHtml, + toJson, + toJsonl, generateExportFilename, -} from '../utils/exportUtils.js'; +} from '../utils/export/index.js'; /** * Action for the 'md' subcommand - exports session to markdown. @@ -62,12 +64,17 @@ async function exportMarkdownAction( const { conversation } = sessionData; - const markdown = transformToMarkdown( + // Collect and normalize export data (SSOT) + const exportData = await collectSessionData(conversation, config); + const normalizedData = normalizeSessionData( + exportData, conversation.messages, - conversation.sessionId, - conversation.startTime, + config, ); + // Generate markdown from SSOT + const markdown = toMarkdown(normalizedData); + const filename = generateExportFilename('md'); const filepath = path.join(cwd, filename); @@ -129,9 +136,16 @@ async function exportHtmlAction( const { conversation } = sessionData; - const template = await loadHtmlTemplate(); - const exportData = prepareExportData(conversation); - const html = injectDataIntoHtmlTemplate(template, exportData); + // Collect and normalize export data (SSOT) + const exportData = await collectSessionData(conversation, config); + const normalizedData = normalizeSessionData( + exportData, + conversation.messages, + config, + ); + + // Generate HTML from SSOT + const html = toHtml(normalizedData); const filename = generateExportFilename('html'); const filepath = path.join(cwd, filename); @@ -153,6 +167,150 @@ async function exportHtmlAction( } } +/** + * Action for the 'json' subcommand - exports session to JSON. + */ +async function exportJsonAction( + context: CommandContext, +): Promise { + const { services } = context; + const { config } = services; + + if (!config) { + return { + type: 'message', + messageType: 'error', + content: 'Configuration not available.', + }; + } + + const cwd = config.getWorkingDir() || config.getProjectRoot(); + if (!cwd) { + return { + type: 'message', + messageType: 'error', + content: 'Could not determine current working directory.', + }; + } + + try { + // Load the current session + const sessionService = new SessionService(cwd); + const sessionData = await sessionService.loadLastSession(); + + if (!sessionData) { + return { + type: 'message', + messageType: 'error', + content: 'No active session found to export.', + }; + } + + const { conversation } = sessionData; + + // Collect and normalize export data (SSOT) + const exportData = await collectSessionData(conversation, config); + const normalizedData = normalizeSessionData( + exportData, + conversation.messages, + config, + ); + + // Generate JSON from SSOT + const json = toJson(normalizedData); + + const filename = generateExportFilename('json'); + const filepath = path.join(cwd, filename); + + // Write to file + await fs.writeFile(filepath, json, 'utf-8'); + + return { + type: 'message', + messageType: 'info', + content: `Session exported to JSON: ${filename}`, + }; + } catch (error) { + return { + type: 'message', + messageType: 'error', + content: `Failed to export session: ${error instanceof Error ? error.message : String(error)}`, + }; + } +} + +/** + * Action for the 'jsonl' subcommand - exports session to JSONL. + */ +async function exportJsonlAction( + context: CommandContext, +): Promise { + const { services } = context; + const { config } = services; + + if (!config) { + return { + type: 'message', + messageType: 'error', + content: 'Configuration not available.', + }; + } + + const cwd = config.getWorkingDir() || config.getProjectRoot(); + if (!cwd) { + return { + type: 'message', + messageType: 'error', + content: 'Could not determine current working directory.', + }; + } + + try { + // Load the current session + const sessionService = new SessionService(cwd); + const sessionData = await sessionService.loadLastSession(); + + if (!sessionData) { + return { + type: 'message', + messageType: 'error', + content: 'No active session found to export.', + }; + } + + const { conversation } = sessionData; + + // Collect and normalize export data (SSOT) + const exportData = await collectSessionData(conversation, config); + const normalizedData = normalizeSessionData( + exportData, + conversation.messages, + config, + ); + + // Generate JSONL from SSOT + const jsonl = toJsonl(normalizedData); + + const filename = generateExportFilename('jsonl'); + const filepath = path.join(cwd, filename); + + // Write to file + await fs.writeFile(filepath, jsonl, 'utf-8'); + + return { + type: 'message', + messageType: 'info', + content: `Session exported to JSONL: ${filename}`, + }; + } catch (error) { + return { + type: 'message', + messageType: 'error', + content: `Failed to export session: ${error instanceof Error ? error.message : String(error)}`, + }; + } +} + /** * Main export command with subcommands. */ @@ -173,5 +331,17 @@ export const exportCommand: SlashCommand = { kind: CommandKind.BUILT_IN, action: exportHtmlAction, }, + { + name: 'json', + description: 'Export session to JSON format', + kind: CommandKind.BUILT_IN, + action: exportJsonAction, + }, + { + name: 'jsonl', + description: 'Export session to JSONL format (one message per line)', + kind: CommandKind.BUILT_IN, + action: exportJsonlAction, + }, ], }; diff --git a/packages/cli/src/ui/utils/export/collect.ts b/packages/cli/src/ui/utils/export/collect.ts new file mode 100644 index 000000000..a051bfccd --- /dev/null +++ b/packages/cli/src/ui/utils/export/collect.ts @@ -0,0 +1,205 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { randomUUID } from 'node:crypto'; +import type { Config, ChatRecord } from '@qwen-code/qwen-code-core'; +import type { SessionContext } from '../../../acp-integration/session/types.js'; +import type * as acp from '../../../acp-integration/acp.js'; +import { HistoryReplayer } from '../../../acp-integration/session/HistoryReplayer.js'; +import type { ExportMessage, ExportSessionData } from './types.js'; + +/** + * Export session context that captures session updates into export messages. + * Implements SessionContext to work with HistoryReplayer. + */ +class ExportSessionContext implements SessionContext { + readonly sessionId: string; + readonly config: Config; + private messages: ExportMessage[] = []; + private currentMessage: { + type: 'user' | 'assistant'; + role: 'user' | 'assistant' | 'thinking'; + parts: Array<{ text: string }>; + timestamp: number; + } | null = null; + private activeRecordId: string | null = null; + private toolCallMap: Map = new Map(); + + constructor(sessionId: string, config: Config) { + this.sessionId = sessionId; + this.config = config; + } + + async sendUpdate(update: acp.SessionUpdate): Promise { + switch (update.sessionUpdate) { + case 'user_message_chunk': + this.handleMessageChunk('user', update.content); + break; + case 'agent_message_chunk': + this.handleMessageChunk('assistant', update.content); + break; + case 'agent_thought_chunk': + this.handleMessageChunk('assistant', update.content, 'thinking'); + break; + case 'tool_call': + this.flushCurrentMessage(); + this.handleToolCallStart(update); + break; + case 'tool_call_update': + this.handleToolCallUpdate(update); + break; + default: + // Ignore other update types + break; + } + } + + setActiveRecordId(recordId: string | null): void { + this.activeRecordId = recordId; + } + + private getMessageUuid(): string { + return this.activeRecordId ?? randomUUID(); + } + + private handleMessageChunk( + role: 'user' | 'assistant', + content: { type: string; text?: string }, + messageRole: 'user' | 'assistant' | 'thinking' = role, + ): void { + if (content.type !== 'text' || !content.text) return; + + // If we're starting a new message type, flush the previous one + if ( + this.currentMessage && + (this.currentMessage.type !== role || + this.currentMessage.role !== messageRole) + ) { + this.flushCurrentMessage(); + } + + // Add to current message or create new one + if ( + this.currentMessage && + this.currentMessage.type === role && + this.currentMessage.role === messageRole + ) { + this.currentMessage.parts.push({ text: content.text }); + } else { + this.currentMessage = { + type: role, + role: messageRole, + parts: [{ text: content.text }], + timestamp: Date.now(), + }; + } + } + + private handleToolCallStart(update: acp.ToolCall): void { + const toolCall: ExportMessage['toolCall'] = { + toolCallId: update.toolCallId, + kind: update.kind || 'other', + title: + typeof update.title === 'string' ? update.title : update.title || '', + status: update.status || 'pending', + rawInput: update.rawInput as string | object | undefined, + locations: update.locations, + timestamp: Date.now(), + }; + + this.toolCallMap.set(update.toolCallId, toolCall); + + // Immediately add tool call to messages to preserve order + const uuid = this.getMessageUuid(); + this.messages.push({ + uuid, + sessionId: this.sessionId, + timestamp: new Date(toolCall.timestamp || Date.now()).toISOString(), + type: 'tool_call', + toolCall, + }); + } + + private handleToolCallUpdate(update: { + toolCallId: string; + status?: 'pending' | 'in_progress' | 'completed' | 'failed' | null; + title?: string | null; + content?: Array<{ type: string; [key: string]: unknown }> | null; + kind?: string | null; + }): void { + const toolCall = this.toolCallMap.get(update.toolCallId); + if (toolCall) { + // Update the tool call in place + if (update.status) toolCall.status = update.status; + if (update.content) toolCall.content = update.content; + if (update.title) + toolCall.title = typeof update.title === 'string' ? update.title : ''; + } + } + + private flushCurrentMessage(): void { + if (!this.currentMessage) return; + + const uuid = this.getMessageUuid(); + this.messages.push({ + uuid, + sessionId: this.sessionId, + timestamp: new Date(this.currentMessage.timestamp).toISOString(), + type: this.currentMessage.type, + message: { + role: this.currentMessage.role, + parts: this.currentMessage.parts, + }, + }); + + this.currentMessage = null; + } + + flushMessages(): void { + this.flushCurrentMessage(); + } + + getMessages(): ExportMessage[] { + return this.messages; + } +} + +/** + * Collects session data from ChatRecord[] using HistoryReplayer. + * Returns the raw ExportSessionData (SSOT) without normalization. + */ +export async function collectSessionData( + conversation: { + sessionId: string; + startTime: string; + messages: ChatRecord[]; + }, + config: Config, +): Promise { + // Create export session context + const exportContext = new ExportSessionContext( + conversation.sessionId, + config, + ); + + // Create history replayer with export context + const replayer = new HistoryReplayer(exportContext); + + // Replay chat records to build export messages + await replayer.replay(conversation.messages); + + // Flush any buffered messages + exportContext.flushMessages(); + + // Get the export messages + const messages = exportContext.getMessages(); + + return { + sessionId: conversation.sessionId, + startTime: conversation.startTime, + messages, + }; +} diff --git a/packages/cli/src/ui/utils/export/formatters/html.ts b/packages/cli/src/ui/utils/export/formatters/html.ts new file mode 100644 index 000000000..a03b8e4b3 --- /dev/null +++ b/packages/cli/src/ui/utils/export/formatters/html.ts @@ -0,0 +1,55 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { ExportSessionData } from '../types.js'; +import { HTML_TEMPLATE } from './htmlTemplate.js'; + +/** + * Escapes JSON for safe embedding in HTML. + */ +function escapeJsonForHtml(json: string): string { + return json + .replace(/&/g, '\\u0026') + .replace(//g, '\\u003e'); +} + +/** + * Loads the HTML template. + * Currently we use an embedded html string. + * Consider using online html template in the future. + */ +export function loadHtmlTemplate(): string { + return HTML_TEMPLATE; +} + +/** + * Injects JSON data into the HTML template. + */ +export function injectDataIntoHtmlTemplate( + template: string, + data: { + sessionId: string; + startTime: string; + messages: unknown[]; + }, +): string { + const jsonData = JSON.stringify(data, null, 2); + const escapedJsonData = escapeJsonForHtml(jsonData); + const html = template.replace( + /`, + ); + return html; +} + +/** + * Converts ExportSessionData to HTML format. + */ +export function toHtml(sessionData: ExportSessionData): string { + const template = loadHtmlTemplate(); + return injectDataIntoHtmlTemplate(template, sessionData); +} diff --git a/packages/cli/src/ui/utils/export/formatters/htmlTemplate.ts b/packages/cli/src/ui/utils/export/formatters/htmlTemplate.ts new file mode 100644 index 000000000..39d8f998e --- /dev/null +++ b/packages/cli/src/ui/utils/export/formatters/htmlTemplate.ts @@ -0,0 +1,362 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +const FAVICON_SVG = + ''; + +export const HTML_TEMPLATE = ` + + + + + + + Qwen Code Chat Export + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
${FAVICON_SVG}
+ +
+
+
+ Session Id + - +
+
+ Export Time + - +
+
+
+ +
+
+ + + + + + + +`; diff --git a/packages/cli/src/ui/utils/export/formatters/json.ts b/packages/cli/src/ui/utils/export/formatters/json.ts new file mode 100644 index 000000000..49942bb95 --- /dev/null +++ b/packages/cli/src/ui/utils/export/formatters/json.ts @@ -0,0 +1,15 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { ExportSessionData } from '../types.js'; + +/** + * Converts ExportSessionData to JSON format. + * Outputs a single JSON object containing the entire session. + */ +export function toJson(sessionData: ExportSessionData): string { + return JSON.stringify(sessionData, null, 2); +} diff --git a/packages/cli/src/ui/utils/export/formatters/jsonl.ts b/packages/cli/src/ui/utils/export/formatters/jsonl.ts new file mode 100644 index 000000000..57dcfeb8b --- /dev/null +++ b/packages/cli/src/ui/utils/export/formatters/jsonl.ts @@ -0,0 +1,31 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { ExportSessionData } from '../types.js'; + +/** + * Converts ExportSessionData to JSONL (JSON Lines) format. + * Each message is output as a separate JSON object on its own line. + */ +export function toJsonl(sessionData: ExportSessionData): string { + const lines: string[] = []; + + // Add session metadata as the first line + lines.push( + JSON.stringify({ + type: 'session_metadata', + sessionId: sessionData.sessionId, + startTime: sessionData.startTime, + }), + ); + + // Add each message as a separate line + for (const message of sessionData.messages) { + lines.push(JSON.stringify(message)); + } + + return lines.join('\n'); +} diff --git a/packages/cli/src/ui/utils/export/formatters/markdown.ts b/packages/cli/src/ui/utils/export/formatters/markdown.ts new file mode 100644 index 000000000..3927cd23e --- /dev/null +++ b/packages/cli/src/ui/utils/export/formatters/markdown.ts @@ -0,0 +1,86 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { ExportSessionData, ExportMessage } from '../types.js'; + +/** + * Converts ExportSessionData to markdown format. + */ +export function toMarkdown(sessionData: ExportSessionData): string { + const lines: string[] = []; + + // Add header with metadata + lines.push('# Chat Session Export\n'); + lines.push(`**Session ID**: ${sessionData.sessionId}\n`); + lines.push(`**Start Time**: ${sessionData.startTime}\n`); + lines.push(`**Exported**: ${new Date().toISOString()}\n`); + lines.push('---\n'); + + // Process each message + for (const message of sessionData.messages) { + if (message.type === 'user') { + lines.push('## User\n'); + const text = extractTextFromMessage(message); + lines.push(`${text}\n`); + } else if (message.type === 'assistant') { + lines.push('## Assistant\n'); + const text = extractTextFromMessage(message); + lines.push(`${text}\n`); + } else if (message.type === 'tool_call') { + lines.push('## Tool Call\n'); + if (message.toolCall) { + const title = + typeof message.toolCall.title === 'string' + ? message.toolCall.title + : JSON.stringify(message.toolCall.title); + lines.push(`**Tool**: ${title}\n`); + lines.push(`**Status**: ${message.toolCall.status}\n`); + + if (message.toolCall.content && message.toolCall.content.length > 0) { + lines.push('```\n'); + for (const contentItem of message.toolCall.content) { + if (contentItem.type === 'content' && contentItem['content']) { + const contentData = contentItem['content'] as { + type: string; + text?: string; + }; + if (contentData.type === 'text' && contentData.text) { + lines.push(contentData.text); + } + } else if (contentItem.type === 'diff') { + lines.push(`Diff for: ${contentItem['path']}\n`); + lines.push(`${contentItem['newText']}\n`); + } + } + lines.push('\n```\n'); + } + } + } else if (message.type === 'system') { + // Skip system messages or format them minimally + lines.push('_[System message]_\n'); + } + + lines.push('\n'); + } + + return lines.join(''); +} + +/** + * Extracts text content from an export message. + */ +function extractTextFromMessage(message: ExportMessage): string { + if (!message.message?.parts) return ''; + + const textParts: string[] = []; + for (const part of message.message.parts) { + if ('text' in part) { + textParts.push(part.text); + } + } + + return textParts.join('\n'); +} diff --git a/packages/cli/src/ui/utils/export/index.ts b/packages/cli/src/ui/utils/export/index.ts new file mode 100644 index 000000000..b19a6114b --- /dev/null +++ b/packages/cli/src/ui/utils/export/index.ts @@ -0,0 +1,18 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +export type { ExportMessage, ExportSessionData } from './types.js'; +export { collectSessionData } from './collect.js'; +export { normalizeSessionData } from './normalize.js'; +export { toMarkdown } from './formatters/markdown.js'; +export { + toHtml, + loadHtmlTemplate, + injectDataIntoHtmlTemplate, +} from './formatters/html.js'; +export { toJson } from './formatters/json.js'; +export { toJsonl } from './formatters/jsonl.js'; +export { generateExportFilename } from './utils.js'; diff --git a/packages/cli/src/ui/utils/export/normalize.ts b/packages/cli/src/ui/utils/export/normalize.ts new file mode 100644 index 000000000..4f59bdc80 --- /dev/null +++ b/packages/cli/src/ui/utils/export/normalize.ts @@ -0,0 +1,291 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Part } from '@google/genai'; +import { ExitPlanModeTool } from '@qwen-code/qwen-code-core'; +import type { ChatRecord, Config, Kind } from '@qwen-code/qwen-code-core'; +import type { ExportMessage, ExportSessionData } from './types.js'; + +/** + * Normalizes export session data by merging tool call information from tool_result records. + * This ensures the SSOT contains complete tool call metadata. + */ +export function normalizeSessionData( + sessionData: ExportSessionData, + originalRecords: ChatRecord[], + config: Config, +): ExportSessionData { + const normalized = [...sessionData.messages]; + const toolCallIndexById = new Map(); + + // Build index of tool call messages + normalized.forEach((message, index) => { + if (message.type === 'tool_call' && message.toolCall?.toolCallId) { + toolCallIndexById.set(message.toolCall.toolCallId, index); + } + }); + + // Merge tool result information into tool call messages + for (const record of originalRecords) { + if (record.type !== 'tool_result') continue; + + const toolCallMessage = buildToolCallMessageFromResult(record, config); + if (!toolCallMessage?.toolCall) continue; + + const existingIndex = toolCallIndexById.get( + toolCallMessage.toolCall.toolCallId, + ); + + if (existingIndex === undefined) { + // No existing tool call, add this one + toolCallIndexById.set( + toolCallMessage.toolCall.toolCallId, + normalized.length, + ); + normalized.push(toolCallMessage); + continue; + } + + // Merge into existing tool call + const existingMessage = normalized[existingIndex]; + if (existingMessage.type !== 'tool_call' || !existingMessage.toolCall) { + continue; + } + + mergeToolCallData(existingMessage.toolCall, toolCallMessage.toolCall); + } + + return { + ...sessionData, + messages: normalized, + }; +} + +/** + * Merges incoming tool call data into existing tool call. + */ +function mergeToolCallData( + existing: NonNullable, + incoming: NonNullable, +): void { + if (!existing.content || existing.content.length === 0) { + existing.content = incoming.content; + } + if (existing.status === 'pending' || existing.status === 'in_progress') { + existing.status = incoming.status; + } + if (!existing.rawInput && incoming.rawInput) { + existing.rawInput = incoming.rawInput; + } + if (!existing.kind || existing.kind === 'other') { + existing.kind = incoming.kind; + } + if ((!existing.title || existing.title === '') && incoming.title) { + existing.title = incoming.title; + } + if ( + (!existing.locations || existing.locations.length === 0) && + incoming.locations && + incoming.locations.length > 0 + ) { + existing.locations = incoming.locations; + } +} + +/** + * Builds a tool call message from a tool_result ChatRecord. + */ +function buildToolCallMessageFromResult( + record: ChatRecord, + config: Config, +): ExportMessage | null { + const toolCallResult = record.toolCallResult; + const toolCallId = toolCallResult?.callId ?? record.uuid; + const toolName = extractToolNameFromRecord(record); + const { kind, title, locations } = resolveToolMetadata( + config, + toolName, + (toolCallResult as { args?: Record } | undefined)?.args, + ); + const rawInput = normalizeRawInput( + (toolCallResult as { args?: unknown } | undefined)?.args, + ); + + const content = + extractDiffContent(toolCallResult?.resultDisplay) ?? + transformPartsToToolCallContent(record.message?.parts ?? []); + + return { + uuid: record.uuid, + parentUuid: record.parentUuid, + sessionId: record.sessionId, + timestamp: record.timestamp, + type: 'tool_call', + toolCall: { + toolCallId, + kind, + title, + status: toolCallResult?.error ? 'failed' : 'completed', + rawInput, + content, + locations, + timestamp: Date.parse(record.timestamp), + }, + }; +} + +/** + * Extracts tool name from a ChatRecord. + */ +function extractToolNameFromRecord(record: ChatRecord): string { + if (!record.message?.parts) { + return ''; + } + + for (const part of record.message.parts) { + if ('functionResponse' in part && part.functionResponse?.name) { + return part.functionResponse.name; + } + } + + return ''; +} + +/** + * Resolves tool metadata (kind, title, locations) from tool registry. + */ +function resolveToolMetadata( + config: Config, + toolName: string, + args?: Record, +): { + kind: string; + title: string | object; + locations?: Array<{ path: string; line?: number | null }>; +} { + const toolRegistry = config.getToolRegistry?.(); + const tool = toolName ? toolRegistry?.getTool?.(toolName) : undefined; + + let title: string | object = tool?.displayName ?? toolName ?? 'tool_call'; + let locations: Array<{ path: string; line?: number | null }> | undefined; + const kind = mapToolKind(tool?.kind as Kind | undefined, toolName); + + if (tool && args) { + try { + const invocation = tool.build(args); + title = `${title}: ${invocation.getDescription()}`; + locations = invocation.toolLocations().map((loc) => ({ + path: loc.path, + line: loc.line ?? null, + })); + } catch { + // Keep defaults on build failure + } + } + + return { kind, title, locations }; +} + +/** + * Maps tool kind to allowed export kinds. + */ +function mapToolKind(kind: Kind | undefined, toolName?: string): string { + if (toolName && toolName === ExitPlanModeTool.Name) { + return 'switch_mode'; + } + + const allowedKinds = new Set([ + 'read', + 'edit', + 'delete', + 'move', + 'search', + 'execute', + 'think', + 'fetch', + 'other', + ]); + + if (kind && allowedKinds.has(kind)) { + return kind; + } + + return 'other'; +} + +/** + * Extracts diff content from tool result display. + */ +function extractDiffContent( + resultDisplay: unknown, +): Array<{ type: string; [key: string]: unknown }> | null { + if (!resultDisplay || typeof resultDisplay !== 'object') { + return null; + } + + const display = resultDisplay as Record; + if ('fileName' in display && 'newContent' in display) { + return [ + { + type: 'diff', + path: display['fileName'] as string, + oldText: (display['originalContent'] as string) ?? '', + newText: display['newContent'] as string, + }, + ]; + } + + return null; +} + +/** + * Normalizes raw input to string or object. + */ +function normalizeRawInput(value: unknown): string | object | undefined { + if (typeof value === 'string') return value; + if (typeof value === 'object' && value !== null) return value; + return undefined; +} + +/** + * Transforms Parts to tool call content array. + */ +function transformPartsToToolCallContent( + parts: Part[], +): Array<{ type: string; [key: string]: unknown }> { + const content: Array<{ type: string; [key: string]: unknown }> = []; + + for (const part of parts) { + if ('text' in part && part.text) { + content.push({ + type: 'content', + content: { type: 'text', text: part.text }, + }); + continue; + } + + if ('functionResponse' in part && part.functionResponse) { + const response = part.functionResponse.response as Record< + string, + unknown + >; + const outputField = response?.['output']; + const errorField = response?.['error']; + const responseText = + typeof outputField === 'string' + ? outputField + : typeof errorField === 'string' + ? errorField + : JSON.stringify(response); + content.push({ + type: 'content', + content: { type: 'text', text: responseText }, + }); + } + } + + return content; +} diff --git a/packages/cli/src/ui/utils/export/types.ts b/packages/cli/src/ui/utils/export/types.ts new file mode 100644 index 000000000..e71612615 --- /dev/null +++ b/packages/cli/src/ui/utils/export/types.ts @@ -0,0 +1,54 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Universal export message format - SSOT for all export formats. + * This is format-agnostic and contains all information needed for any export type. + */ +export interface ExportMessage { + uuid: string; + parentUuid?: string | null; + sessionId?: string; + timestamp: string; + type: 'user' | 'assistant' | 'system' | 'tool_call'; + + /** For user/assistant messages */ + message?: { + role?: string; + parts?: Array<{ text: string }>; + content?: string; + }; + + /** Model used for assistant messages */ + model?: string; + + /** For tool_call messages */ + toolCall?: { + toolCallId: string; + kind: string; + title: string | object; + status: 'pending' | 'in_progress' | 'completed' | 'failed'; + rawInput?: string | object; + content?: Array<{ + type: string; + [key: string]: unknown; + }>; + locations?: Array<{ + path: string; + line?: number | null; + }>; + timestamp?: number; + }; +} + +/** + * Complete export session data - the single source of truth. + */ +export interface ExportSessionData { + sessionId: string; + startTime: string; + messages: ExportMessage[]; +} diff --git a/packages/cli/src/ui/utils/export/utils.ts b/packages/cli/src/ui/utils/export/utils.ts new file mode 100644 index 000000000..261bfb073 --- /dev/null +++ b/packages/cli/src/ui/utils/export/utils.ts @@ -0,0 +1,13 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Generates a filename with timestamp for export files. + */ +export function generateExportFilename(extension: string): string { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + return `export-${timestamp}.${extension}`; +} diff --git a/packages/cli/src/ui/utils/exportUtils.test.ts b/packages/cli/src/ui/utils/exportUtils.test.ts deleted file mode 100644 index b58b5a73e..000000000 --- a/packages/cli/src/ui/utils/exportUtils.test.ts +++ /dev/null @@ -1,401 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, it, expect } from 'vitest'; -import { - extractTextFromContent, - transformToMarkdown, - loadHtmlTemplate, - prepareExportData, - injectDataIntoHtmlTemplate, - generateExportFilename, -} from './exportUtils.js'; -import type { ChatRecord } from '@qwen-code/qwen-code-core'; -import type { Part, Content } from '@google/genai'; - -describe('exportUtils', () => { - describe('extractTextFromContent', () => { - it('should return empty string for undefined content', () => { - expect(extractTextFromContent(undefined)).toBe(''); - }); - - it('should return empty string for content without parts', () => { - expect(extractTextFromContent({} as Content)).toBe(''); - }); - - it('should extract text from text parts', () => { - const content: Content = { - parts: [{ text: 'Hello' }, { text: 'World' }] as Part[], - }; - expect(extractTextFromContent(content)).toBe('Hello\nWorld'); - }); - - it('should format function call parts', () => { - const content: Content = { - parts: [ - { - functionCall: { - name: 'testFunction', - args: { param1: 'value1' }, - }, - }, - ] as Part[], - }; - const result = extractTextFromContent(content); - expect(result).toContain('[Function Call: testFunction]'); - expect(result).toContain('"param1": "value1"'); - }); - - it('should format function response parts', () => { - const content: Content = { - parts: [ - { - functionResponse: { - name: 'testFunction', - response: { result: 'success' }, - }, - }, - ] as Part[], - }; - const result = extractTextFromContent(content); - expect(result).toContain('[Function Response: testFunction]'); - expect(result).toContain('"result": "success"'); - }); - - it('should handle mixed part types', () => { - const content: Content = { - parts: [ - { text: 'Start' }, - { - functionCall: { - name: 'call', - args: {}, - }, - }, - { text: 'End' }, - ] as Part[], - }; - const result = extractTextFromContent(content); - expect(result).toContain('Start'); - expect(result).toContain('[Function Call: call]'); - expect(result).toContain('End'); - }); - }); - - describe('transformToMarkdown', () => { - const mockMessages: ChatRecord[] = [ - { - uuid: 'uuid-1', - parentUuid: null, - sessionId: 'test-session-id', - timestamp: '2025-01-01T00:00:00Z', - type: 'user', - cwd: '/test', - version: '1.0.0', - message: { - parts: [{ text: 'Hello, how are you?' }] as Part[], - } as Content, - }, - { - uuid: 'uuid-2', - parentUuid: 'uuid-1', - sessionId: 'test-session-id', - timestamp: '2025-01-01T00:00:01Z', - type: 'assistant', - cwd: '/test', - version: '1.0.0', - message: { - parts: [{ text: 'I am doing well, thank you!' }] as Part[], - } as Content, - }, - ]; - - it('should transform messages to markdown format', () => { - const result = transformToMarkdown( - mockMessages, - 'test-session-id', - '2025-01-01T00:00:00Z', - ); - - expect(result).toContain('# Chat Session Export'); - expect(result).toContain('**Session ID**: test-session-id'); - expect(result).toContain('**Start Time**: 2025-01-01T00:00:00Z'); - expect(result).toContain('## User'); - expect(result).toContain('Hello, how are you?'); - expect(result).toContain('## Assistant'); - expect(result).toContain('I am doing well, thank you!'); - }); - - it('should include exported timestamp', () => { - const before = new Date().toISOString(); - const result = transformToMarkdown( - mockMessages, - 'test-session-id', - '2025-01-01T00:00:00Z', - ); - const after = new Date().toISOString(); - - expect(result).toContain('**Exported**:'); - const exportedMatch = result.match(/\*\*Exported\*\*: (.+)/); - expect(exportedMatch).toBeTruthy(); - if (exportedMatch) { - const exportedTime = exportedMatch[1].trim(); - expect(exportedTime >= before).toBe(true); - expect(exportedTime <= after).toBe(true); - } - }); - - it('should format tool_result messages', () => { - const messages: ChatRecord[] = [ - { - uuid: 'uuid-3', - parentUuid: 'uuid-2', - sessionId: 'test-session-id', - timestamp: '2025-01-01T00:00:02Z', - type: 'tool_result', - cwd: '/test', - version: '1.0.0', - toolCallResult: { - resultDisplay: 'Tool output', - }, - message: { - parts: [{ text: 'Additional info' }] as Part[], - } as Content, - }, - ]; - - const result = transformToMarkdown( - messages, - 'test-session-id', - '2025-01-01T00:00:00Z', - ); - - expect(result).toContain('## Tool Result'); - expect(result).toContain('```'); - expect(result).toContain('Tool output'); - expect(result).toContain('Additional info'); - }); - - it('should format tool_result with JSON resultDisplay', () => { - const messages: ChatRecord[] = [ - { - uuid: 'uuid-4', - parentUuid: 'uuid-3', - sessionId: 'test-session-id', - timestamp: '2025-01-01T00:00:03Z', - type: 'tool_result', - cwd: '/test', - version: '1.0.0', - toolCallResult: { - resultDisplay: '{"key": "value"}', - }, - message: {} as Content, - }, - ]; - - const result = transformToMarkdown( - messages, - 'test-session-id', - '2025-01-01T00:00:00Z', - ); - - expect(result).toContain('## Tool Result'); - expect(result).toContain('```'); - expect(result).toContain('"key": "value"'); - }); - - it('should handle chat compression system messages', () => { - const messages: ChatRecord[] = [ - { - uuid: 'uuid-5', - parentUuid: null, - sessionId: 'test-session-id', - timestamp: '2025-01-01T00:00:04Z', - type: 'system', - subtype: 'chat_compression', - cwd: '/test', - version: '1.0.0', - message: {} as Content, - }, - ]; - - const result = transformToMarkdown( - messages, - 'test-session-id', - '2025-01-01T00:00:00Z', - ); - - expect(result).toContain('_[Chat history compressed]_'); - }); - - it('should skip system messages without subtype', () => { - const messages: ChatRecord[] = [ - { - uuid: 'uuid-6', - parentUuid: null, - sessionId: 'test-session-id', - timestamp: '2025-01-01T00:00:05Z', - type: 'system', - cwd: '/test', - version: '1.0.0', - message: {} as Content, - }, - ]; - - const result = transformToMarkdown( - messages, - 'test-session-id', - '2025-01-01T00:00:00Z', - ); - - expect(result).not.toContain('## System'); - }); - }); - - describe('loadHtmlTemplate', () => { - it('should load HTML template from bundled constant', async () => { - const result = await loadHtmlTemplate(); - - expect(result).toContain(''); - expect(result).toContain('Qwen Code Chat Export'); - expect(result).toContain('id="chat-data"'); - }); - }); - - describe('prepareExportData', () => { - it('should prepare export data from conversation', () => { - const conversation = { - sessionId: 'test-session-id', - startTime: '2025-01-01T00:00:00Z', - messages: [ - { - type: 'user', - message: { - parts: [{ text: 'Hello' }] as Part[], - } as Content, - }, - ] as ChatRecord[], - }; - - const result = prepareExportData(conversation); - - expect(result).toEqual({ - sessionId: 'test-session-id', - startTime: '2025-01-01T00:00:00Z', - messages: conversation.messages, - }); - }); - }); - - describe('injectDataIntoHtmlTemplate', () => { - it('should inject JSON data into HTML template', () => { - const template = ` - - - - - - `; - - const data = { - sessionId: 'test-session-id', - startTime: '2025-01-01T00:00:00Z', - messages: [] as ChatRecord[], - }; - - const result = injectDataIntoHtmlTemplate(template, data); - - expect(result).toContain( - '`; - - const data = { - sessionId: 'test', - startTime: '2025-01-01T00:00:00Z', - messages: [] as ChatRecord[], - }; - - const result = injectDataIntoHtmlTemplate(template, data); - - expect(result).toContain('"sessionId": "test"'); - expect(result).not.toContain('DATA_PLACEHOLDER'); - }); - - it('should escape unsafe JSON sequences in HTML', () => { - const template = ` - - - - - - `; - - const data = { - sessionId: 'test-session-id', - startTime: '2025-01-01T00:00:00Z', - messages: [ - { - uuid: 'uuid-1', - parentUuid: null, - sessionId: 'test-session-id', - timestamp: '2025-01-01T00:00:00Z', - type: 'user', - cwd: '/test', - version: '1.0.0', - message: { - parts: [{ text: '
unsafe
' }] as Part[], - } as Content, - }, - ] as ChatRecord[], - }; - - const result = injectDataIntoHtmlTemplate(template, data); - - expect(result).toContain('\\u003c/script'); - expect(result).not.toContain('
unsafe
'); - }); - }); - - describe('generateExportFilename', () => { - it('should generate filename with timestamp and extension', () => { - const filename = generateExportFilename('md'); - - expect(filename).toMatch( - /^export-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z\.md$/, - ); - }); - - it('should use provided extension', () => { - const filename1 = generateExportFilename('html'); - const filename2 = generateExportFilename('json'); - - expect(filename1).toMatch(/\.html$/); - expect(filename2).toMatch(/\.json$/); - }); - - it('should replace colons and dots in timestamp', () => { - const filename = generateExportFilename('md'); - - expect(filename).not.toContain(':'); - // The filename should contain a dot only for the extension - expect(filename.split('.').length).toBe(2); - // Check that timestamp part (before extension) doesn't contain dots - const timestampPart = filename.split('.')[0]; - expect(timestampPart).not.toContain('.'); - }); - }); -}); diff --git a/packages/cli/src/ui/utils/exportUtils.ts b/packages/cli/src/ui/utils/exportUtils.ts deleted file mode 100644 index 8e3d53ba4..000000000 --- a/packages/cli/src/ui/utils/exportUtils.ts +++ /dev/null @@ -1,349 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { Part, Content } from '@google/genai'; -import type { ChatRecord } from '@qwen-code/qwen-code-core'; - -const HTML_TEMPLATE = ` - - - - - - Qwen Code Chat Export - - - - - - - - - - - - - - - - - -
-
-

Qwen Code Chat Export

-
-
- Session ID: - - -
-
- Date: - - -
-
-
- -
-
- - - - - - - -`; - -function escapeJsonForHtml(json: string): string { - return json - .replace(/&/g, '\\u0026') - .replace(//g, '\\u003e'); -} - -/** - * Extracts text content from a Content object's parts. - */ -export function extractTextFromContent(content: Content | undefined): string { - if (!content?.parts) return ''; - - const textParts: string[] = []; - for (const part of content.parts as Part[]) { - if ('text' in part) { - const textPart = part as { text: string }; - textParts.push(textPart.text); - } else if ('functionCall' in part) { - const fnPart = part as { functionCall: { name: string; args: unknown } }; - textParts.push( - `[Function Call: ${fnPart.functionCall.name}]\n${JSON.stringify(fnPart.functionCall.args, null, 2)}`, - ); - } else if ('functionResponse' in part) { - const fnResPart = part as { - functionResponse: { name: string; response: unknown }; - }; - textParts.push( - `[Function Response: ${fnResPart.functionResponse.name}]\n${JSON.stringify(fnResPart.functionResponse.response, null, 2)}`, - ); - } - } - - return textParts.join('\n'); -} - -/** - * Transforms ChatRecord messages to markdown format. - */ -export function transformToMarkdown( - messages: ChatRecord[], - sessionId: string, - startTime: string, -): string { - const lines: string[] = []; - - // Add header with metadata - lines.push('# Chat Session Export\n'); - lines.push(`**Session ID**: ${sessionId}\n`); - lines.push(`**Start Time**: ${startTime}\n`); - lines.push(`**Exported**: ${new Date().toISOString()}\n`); - lines.push('---\n'); - - // Process each message - for (const record of messages) { - if (record.type === 'user') { - lines.push('## User\n'); - const text = extractTextFromContent(record.message); - lines.push(`${text}\n`); - } else if (record.type === 'assistant') { - lines.push('## Assistant\n'); - const text = extractTextFromContent(record.message); - lines.push(`${text}\n`); - } else if (record.type === 'tool_result') { - lines.push('## Tool Result\n'); - if (record.toolCallResult) { - const resultDisplay = record.toolCallResult.resultDisplay; - if (resultDisplay) { - lines.push('```\n'); - lines.push( - typeof resultDisplay === 'string' - ? resultDisplay - : JSON.stringify(resultDisplay, null, 2), - ); - lines.push('\n```\n'); - } - } - const text = extractTextFromContent(record.message); - if (text) { - lines.push(`${text}\n`); - } - } else if (record.type === 'system') { - // Skip system messages or format them minimally - if (record.subtype === 'chat_compression') { - lines.push('_[Chat history compressed]_\n'); - } - } - - lines.push('\n'); - } - - return lines.join(''); -} - -/** - * Loads the HTML template from a bundled string constant. - */ -export async function loadHtmlTemplate(): Promise { - return HTML_TEMPLATE; -} - -/** - * Prepares export data from conversation. - */ -export function prepareExportData(conversation: { - sessionId: string; - startTime: string; - messages: ChatRecord[]; -}): { - sessionId: string; - startTime: string; - messages: ChatRecord[]; -} { - return { - sessionId: conversation.sessionId, - startTime: conversation.startTime, - messages: conversation.messages, - }; -} - -/** - * Injects JSON data into the HTML template. - */ -export function injectDataIntoHtmlTemplate( - template: string, - data: { - sessionId: string; - startTime: string; - messages: ChatRecord[]; - }, -): string { - const jsonData = JSON.stringify(data, null, 2); - const escapedJsonData = escapeJsonForHtml(jsonData); - const html = template.replace( - /`, - ); - return html; -} - -/** - * Generates a filename with timestamp for export files. - */ -export function generateExportFilename(extension: string): string { - const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); - return `export-${timestamp}.${extension}`; -} From 86a43618a7ead70000d6b876a17d861cb015a960 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Thu, 29 Jan 2026 15:18:56 +0800 Subject: [PATCH 40/49] refactor(export): built-in package for assets export 1. use built-in assets package to support html export 2. improve markdown export --- eslint.config.js | 19 + packages/cli/assets/export-html/build.mjs | 71 ++++ .../cli/assets/export-html/esbuild.config.mjs | 27 ++ packages/cli/assets/export-html/package.json | 16 + .../cli/assets/export-html/src/favicon.svg | 6 + .../cli/assets/export-html/src/index.html | 33 ++ packages/cli/assets/export-html/src/main.tsx | 137 +++++++ .../cli/assets/export-html/src/styles.css | 203 ++++++++++ .../cli/assets/export-html/src/types.d.ts | 9 + packages/cli/assets/export-html/tsconfig.json | 15 + packages/cli/assets/parallel-build.mjs | 92 +++++ packages/cli/package.json | 3 +- packages/cli/src/ui/commands/exportCommand.ts | 12 +- .../src/ui/utils/export/formatters/html.ts | 4 +- .../utils/export/formatters/htmlTemplate.ts | 360 +----------------- .../ui/utils/export/formatters/markdown.ts | 217 +++++++++-- 16 files changed, 819 insertions(+), 405 deletions(-) create mode 100644 packages/cli/assets/export-html/build.mjs create mode 100644 packages/cli/assets/export-html/esbuild.config.mjs create mode 100644 packages/cli/assets/export-html/package.json create mode 100644 packages/cli/assets/export-html/src/favicon.svg create mode 100644 packages/cli/assets/export-html/src/index.html create mode 100644 packages/cli/assets/export-html/src/main.tsx create mode 100644 packages/cli/assets/export-html/src/styles.css create mode 100644 packages/cli/assets/export-html/src/types.d.ts create mode 100644 packages/cli/assets/export-html/tsconfig.json create mode 100644 packages/cli/assets/parallel-build.mjs diff --git a/eslint.config.js b/eslint.config.js index ea3158688..bd3585a92 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -258,6 +258,25 @@ export default tseslint.config( '@typescript-eslint/no-require-imports': 'off', }, }, + // Settings for export-html assets + { + files: ['packages/cli/assets/export-html/**/*.{js,jsx,ts,tsx}'], + languageOptions: { + globals: { + ...globals.browser, + ...globals.node, + }, + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + }, + }, + rules: { + 'react/react-in-jsx-scope': 'off', + 'react/prop-types': 'off', + }, + }, // Prettier config must be last prettierConfig, // extra settings for scripts that we run directly with node diff --git a/packages/cli/assets/export-html/build.mjs b/packages/cli/assets/export-html/build.mjs new file mode 100644 index 000000000..84a62f456 --- /dev/null +++ b/packages/cli/assets/export-html/build.mjs @@ -0,0 +1,71 @@ +import { readFile, writeFile, mkdir } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { build } from 'esbuild'; +import { buildConfig } from './esbuild.config.mjs'; + +const assetsDir = dirname(fileURLToPath(import.meta.url)); +const srcDir = join(assetsDir, 'src'); +const assetsDistDir = join(assetsDir, 'dist'); +const packageDistDir = join( + assetsDir, + '..', + '..', + 'dist', + 'assets', + 'export-html', +); +const templateModulePath = join( + assetsDir, + '..', + '..', + 'src', + 'ui', + 'utils', + 'export', + 'formatters', + 'htmlTemplate.ts', +); + +await mkdir(assetsDistDir, { recursive: true }); +await mkdir(packageDistDir, { recursive: true }); + +const buildResult = await build(buildConfig); + +const jsBundle = buildResult.outputFiles.find((file) => + file.path.endsWith('.js'), +); +const cssBundle = buildResult.outputFiles.find((file) => + file.path.endsWith('.css'), +); +if (!jsBundle) { + throw new Error('Failed to generate inline script bundle.'); +} + +const css = cssBundle + ? cssBundle.text + : await readFile(join(srcDir, 'styles.css'), 'utf8'); +const htmlTemplate = await readFile(join(srcDir, 'index.html'), 'utf8'); +const faviconSvg = await readFile(join(srcDir, 'favicon.svg'), 'utf8'); +const faviconData = encodeURIComponent(faviconSvg.trim()); + +const htmlOutput = htmlTemplate + .replace('__INLINE_CSS__', css.trim()) + .replace('__INLINE_SCRIPT__', jsBundle.text.trim()) + .replace('__FAVICON_SVG__', faviconSvg.trim()) + .replace('__FAVICON_DATA__', faviconData); + +const templateModule = `/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * This HTML template is code-generated; do not edit manually. + */ + +export const HTML_TEMPLATE = ${JSON.stringify(htmlOutput)}; +`; + +await writeFile(join(assetsDistDir, 'index.html'), htmlOutput); +await writeFile(join(packageDistDir, 'index.html'), htmlOutput); +await writeFile(templateModulePath, templateModule); diff --git a/packages/cli/assets/export-html/esbuild.config.mjs b/packages/cli/assets/export-html/esbuild.config.mjs new file mode 100644 index 000000000..b2a486cb2 --- /dev/null +++ b/packages/cli/assets/export-html/esbuild.config.mjs @@ -0,0 +1,27 @@ +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { dirname } from 'node:path'; + +const assetsDir = dirname(fileURLToPath(import.meta.url)); +const srcDir = join(assetsDir, 'src'); + +export const buildConfig = { + entryPoints: [join(srcDir, 'main.tsx')], + bundle: true, + minify: true, + write: false, + outdir: join(assetsDir, 'dist'), + platform: 'browser', + format: 'iife', + target: ['es2018'], + jsx: 'automatic', + legalComments: 'none', + loader: { + '.ts': 'ts', + '.tsx': 'tsx', + '.js': 'jsx', + '.jsx': 'jsx', + '.css': 'css', + '.svg': 'text', + }, +}; diff --git a/packages/cli/assets/export-html/package.json b/packages/cli/assets/export-html/package.json new file mode 100644 index 000000000..173fb73f4 --- /dev/null +++ b/packages/cli/assets/export-html/package.json @@ -0,0 +1,16 @@ +{ + "name": "@qwen-code/cli-export-html", + "private": true, + "type": "module", + "scripts": { + "build": "node build.mjs" + }, + "dependencies": { + "@qwen-code/webui": "^0.1.0-beta.4", + "react": "^19.1.0", + "react-dom": "^19.1.0" + }, + "devDependencies": { + "esbuild": "^0.25.0" + } +} diff --git a/packages/cli/assets/export-html/src/favicon.svg b/packages/cli/assets/export-html/src/favicon.svg new file mode 100644 index 000000000..d489bdabf --- /dev/null +++ b/packages/cli/assets/export-html/src/favicon.svg @@ -0,0 +1,6 @@ + + + diff --git a/packages/cli/assets/export-html/src/index.html b/packages/cli/assets/export-html/src/index.html new file mode 100644 index 000000000..2255d62ff --- /dev/null +++ b/packages/cli/assets/export-html/src/index.html @@ -0,0 +1,33 @@ + + + + + + + Qwen Code Chat Export + + + +
+ + + + + diff --git a/packages/cli/assets/export-html/src/main.tsx b/packages/cli/assets/export-html/src/main.tsx new file mode 100644 index 000000000..b496654fb --- /dev/null +++ b/packages/cli/assets/export-html/src/main.tsx @@ -0,0 +1,137 @@ +import { createRoot } from 'react-dom/client'; +import { ChatViewer, PlatformProvider } from '@qwen-code/webui'; +import '@qwen-code/webui/styles.css'; +import './styles.css'; +import logoSvg from './favicon.svg'; + +type ChatData = { + messages?: unknown[]; + sessionId?: string; + startTime?: string; +}; + +type PlatformContextValue = Parameters[0]['value']; +type ChatViewerMessage = Parameters[0]['messages'][number]; + +const logoSvgWithGradient = (() => { + if (!logoSvg) { + return logoSvg; + } + + const gradientDef = + ''; + + const withDefs = logoSvg.replace(/]*)>/, `${gradientDef}`); + + return withDefs.replace(/fill="[^"]*"/, 'fill="url(#qwen-logo-gradient)"'); +})(); + +const platformContext = { + platform: 'web' as PlatformContextValue['platform'], + postMessage: (message: unknown) => { + console.log('Posted message:', message); + }, + onMessage: (handler: (event: MessageEvent) => void) => { + window.addEventListener('message', handler); + return () => window.removeEventListener('message', handler); + }, + openFile: (path: string) => { + console.log('Opening file:', path); + }, + getResourceUrl: () => undefined, + features: { + canOpenFile: false, + canCopy: true, + }, +} satisfies PlatformContextValue; + +const isChatViewerMessage = (value: unknown): value is ChatViewerMessage => + Boolean(value) && typeof value === 'object'; + +const parseChatData = (): ChatData => { + const chatDataElement = document.getElementById('chat-data'); + if (!chatDataElement?.textContent) { + return {}; + } + + try { + const parsed = JSON.parse(chatDataElement.textContent) as unknown; + if (parsed && typeof parsed === 'object') { + return parsed as ChatData; + } + return {}; + } catch (error) { + console.error('Failed to parse chat data.', error); + return {}; + } +}; + +const formatSessionDate = (startTime?: string | null) => { + if (!startTime) { + return '-'; + } + + try { + const date = new Date(startTime); + return date.toLocaleString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + } catch { + return startTime; + } +}; + +const App = () => { + const chatData = parseChatData(); + const rawMessages = Array.isArray(chatData.messages) ? chatData.messages : []; + const messages = rawMessages + .filter(isChatViewerMessage) + .filter((record) => record.type !== 'system'); + const sessionId = chatData.sessionId ?? '-'; + const sessionDate = formatSessionDate(chatData.startTime); + + return ( +
+
+
+ +
+
+ Session Id + {sessionId} +
+
+ Export Time + {sessionDate} +
+
+
+
+ + + +
+
+ ); +}; + +const rootElement = document.getElementById('app'); +if (!rootElement) { + console.error('App container not found.'); +} else { + createRoot(rootElement).render(); +} diff --git a/packages/cli/assets/export-html/src/styles.css b/packages/cli/assets/export-html/src/styles.css new file mode 100644 index 000000000..e8286b2c5 --- /dev/null +++ b/packages/cli/assets/export-html/src/styles.css @@ -0,0 +1,203 @@ +:root { + --bg-primary: #18181b; + --bg-secondary: #27272a; + --text-primary: #f4f4f5; + --text-secondary: #a1a1aa; + --border-color: #3f3f46; + --accent-color: #3b82f6; +} + +body { + font-family: + ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', + 'Courier New', monospace; + margin: 0; + padding: 0; + background-color: var(--bg-primary); + color: var(--text-primary); + line-height: 1.6; + -webkit-font-smoothing: antialiased; +} + +.page-wrapper { + min-height: 100vh; + display: flex; + flex-direction: column; + align-items: center; +} + +.header { + width: 100%; + padding: 16px 24px; + border-bottom: 1px solid var(--border-color); + background-color: rgba(24, 24, 27, 0.95); + backdrop-filter: blur(8px); + position: sticky; + top: 0; + z-index: 100; + display: flex; + justify-content: space-between; + align-items: center; + box-sizing: border-box; +} + +.header-left { + display: flex; + align-items: center; + gap: 12px; +} + +.logo-icon { + width: 24px; + height: 24px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.logo-icon svg { + width: 100%; + height: 100%; +} + +.logo { + display: flex; + flex-direction: column; + line-height: 1; +} + +.logo-text { + font-family: 'Press Start 2P', cursive; + font-weight: 400; + font-size: 24px; + letter-spacing: -0.05em; + position: relative; + color: white; +} + +.logo-text-inner { + background: linear-gradient(to right, #60a5fa, #a855f7); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + position: relative; + z-index: 2; +} + +.logo-text::before, +.logo-text::after { + content: attr(data-text); + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 1; + background: none; + -webkit-text-fill-color: transparent; + -webkit-text-stroke: 1px rgba(96, 165, 250, 0.3); +} + +.logo-text::before { + transform: translate(2px, 2px); + -webkit-text-stroke: 1px rgba(168, 85, 247, 0.3); +} + +.logo-text::after { + transform: translate(4px, 4px); + opacity: 0.4; +} + +.logo-sub { + font-size: 11px; + font-weight: 600; + color: var(--text-secondary); + letter-spacing: 0.05em; + text-transform: uppercase; + margin-top: 4px; +} + +.badge { + font-size: 11px; + padding: 2px 8px; + border-radius: 999px; + background-color: var(--bg-secondary); + color: var(--text-secondary); + border: 1px solid var(--border-color); + font-weight: 500; +} + +.meta { + display: flex; + gap: 24px; + font-size: 13px; + color: var(--text-secondary); +} + +.meta-item { + display: flex; + align-items: center; + gap: 8px; +} + +.meta-label { + color: #71717a; +} + +.chat-container { + width: 100%; + max-width: 900px; + padding: 40px 20px; + box-sizing: border-box; + flex: 1; +} + +::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +::-webkit-scrollbar-track { + background: var(--bg-primary); +} + +::-webkit-scrollbar-thumb { + background: var(--bg-secondary); + border-radius: 5px; + border: 2px solid var(--bg-primary); +} + +::-webkit-scrollbar-thumb:hover { + background: #52525b; +} + +@media (max-width: 768px) { + .chat-container { + max-width: 100%; + padding: 20px 16px; + } + + .header { + padding: 12px 16px; + flex-direction: column; + align-items: flex-start; + gap: 12px; + } + + .header-left { + width: 100%; + justify-content: space-between; + } + + .meta { + width: 100%; + flex-direction: column; + gap: 6px; + } +} + +@media (max-width: 480px) { + .chat-container { + padding: 16px 12px; + } +} diff --git a/packages/cli/assets/export-html/src/types.d.ts b/packages/cli/assets/export-html/src/types.d.ts new file mode 100644 index 000000000..9077ec08c --- /dev/null +++ b/packages/cli/assets/export-html/src/types.d.ts @@ -0,0 +1,9 @@ +declare module '*.svg' { + const content: string; + export default content; +} + +declare module '*.css' { + const content: string; + export default content; +} diff --git a/packages/cli/assets/export-html/tsconfig.json b/packages/cli/assets/export-html/tsconfig.json new file mode 100644 index 000000000..5878beab6 --- /dev/null +++ b/packages/cli/assets/export-html/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "strict": true, + "jsx": "react-jsx", + "target": "es2018", + "module": "esnext", + "moduleResolution": "bundler", + "lib": ["ES2021", "DOM", "DOM.Iterable"], + "isolatedModules": true, + "skipLibCheck": true, + "noEmit": true, + "resolveJsonModule": true + }, + "include": ["src"] +} diff --git a/packages/cli/assets/parallel-build.mjs b/packages/cli/assets/parallel-build.mjs new file mode 100644 index 000000000..83fc22471 --- /dev/null +++ b/packages/cli/assets/parallel-build.mjs @@ -0,0 +1,92 @@ +import { access, readdir } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import process from 'node:process'; + +const assetsDir = dirname(fileURLToPath(import.meta.url)); +const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'; + +const entries = await readdir(assetsDir, { withFileTypes: true }); +const assetBuilds = []; + +for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + + const assetPath = join(assetsDir, entry.name); + const buildPath = join(assetPath, 'build.mjs'); + const packageJsonPath = join(assetPath, 'package.json'); + let hasBuild = false; + let hasPackageJson = false; + + try { + await access(buildPath); + hasBuild = true; + } catch { + // ignore missing build.mjs + } + + try { + await access(packageJsonPath); + hasPackageJson = true; + } catch { + // ignore missing package.json + } + + if (hasBuild || hasPackageJson) { + assetBuilds.push({ + name: entry.name, + assetPath, + buildPath, + useNpm: hasPackageJson, + }); + } +} + +if (assetBuilds.length === 0) { + process.exit(0); +} + +const runCommand = ({ command, args, cwd, label }) => + new Promise((resolve, reject) => { + const child = spawn(command, args, { cwd, stdio: 'inherit' }); + + child.on('error', reject); + child.on('exit', (code) => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`${label} failed for ${cwd}.`)); + } + }); + }); + +const runBuild = async (asset) => { + if (asset.useNpm) { + await runCommand({ + command: npmCommand, + args: ['install'], + cwd: asset.assetPath, + label: `npm install`, + }); + + await runCommand({ + command: npmCommand, + args: ['run', 'build'], + cwd: asset.assetPath, + label: `npm run build`, + }); + return; + } + + await runCommand({ + command: process.execPath, + args: [asset.buildPath], + cwd: asset.assetPath, + label: `Node build`, + }); +}; + +await Promise.all(assetBuilds.map((asset) => runBuild(asset))); diff --git a/packages/cli/package.json b/packages/cli/package.json index 14e9a460e..b0bd1fcbc 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -19,7 +19,8 @@ } }, "scripts": { - "build": "node ../../scripts/build_package.js", + "build:assets": "node ./assets/parallel-build.mjs", + "build": "npm run build:assets && node ../../scripts/build_package.js", "start": "node dist/index.js", "debug": "node --inspect-brk dist/index.js", "lint": "eslint . --ext .ts,.tsx", diff --git a/packages/cli/src/ui/commands/exportCommand.ts b/packages/cli/src/ui/commands/exportCommand.ts index 0e5160f41..42af225ac 100644 --- a/packages/cli/src/ui/commands/exportCommand.ts +++ b/packages/cli/src/ui/commands/exportCommand.ts @@ -319,18 +319,18 @@ export const exportCommand: SlashCommand = { description: 'Export current session message history to a file', kind: CommandKind.BUILT_IN, subCommands: [ - { - name: 'md', - description: 'Export session to markdown format', - kind: CommandKind.BUILT_IN, - action: exportMarkdownAction, - }, { name: 'html', description: 'Export session to HTML format', kind: CommandKind.BUILT_IN, action: exportHtmlAction, }, + { + name: 'md', + description: 'Export session to markdown format', + kind: CommandKind.BUILT_IN, + action: exportMarkdownAction, + }, { name: 'json', description: 'Export session to JSON format', diff --git a/packages/cli/src/ui/utils/export/formatters/html.ts b/packages/cli/src/ui/utils/export/formatters/html.ts index a03b8e4b3..5653ed299 100644 --- a/packages/cli/src/ui/utils/export/formatters/html.ts +++ b/packages/cli/src/ui/utils/export/formatters/html.ts @@ -18,9 +18,7 @@ function escapeJsonForHtml(json: string): string { } /** - * Loads the HTML template. - * Currently we use an embedded html string. - * Consider using online html template in the future. + * Loads the HTML template built from assets. */ export function loadHtmlTemplate(): string { return HTML_TEMPLATE; diff --git a/packages/cli/src/ui/utils/export/formatters/htmlTemplate.ts b/packages/cli/src/ui/utils/export/formatters/htmlTemplate.ts index 39d8f998e..d6a3304de 100644 --- a/packages/cli/src/ui/utils/export/formatters/htmlTemplate.ts +++ b/packages/cli/src/ui/utils/export/formatters/htmlTemplate.ts @@ -2,361 +2,9 @@ * @license * Copyright 2025 Qwen Team * SPDX-License-Identifier: Apache-2.0 + * + * This HTML template is code-generated; do not edit manually. */ -const FAVICON_SVG = - ''; - -export const HTML_TEMPLATE = ` - - - - - - - Qwen Code Chat Export - - - - - - - - - - - - - - - - - - - - - - -
-
-
-
${FAVICON_SVG}
- -
-
-
- Session Id - - -
-
- Export Time - - -
-
-
- -
-
- - - - - - - -`; +export const HTML_TEMPLATE = + '\n\n \n \n \n